From 645a324a5fd4cb87efcc4f6f2c538ae234df145c Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 1 Aug 2026 16:06:08 +0700 Subject: [PATCH] feat(navigation): add go to album actions --- lib/screens/now_playing_screen.dart | 87 +++++++++++++++++++ lib/screens/track_metadata_screen.dart | 3 + lib/screens/track_metadata_screen_menu.dart | 57 ++++++++++++ lib/utils/clickable_metadata.dart | 9 +- .../track_collection_quick_actions.dart | 24 +++++ test/now_playing_lyrics_transition_test.dart | 57 ++++++++---- test/preview_button_layout_test.dart | 39 +++++++++ 7 files changed, 254 insertions(+), 22 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 206c094e..93fc71e2 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -5,9 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; @@ -729,6 +733,12 @@ class _NowPlayingScreenState extends ConsumerState { padding: const EdgeInsets.only(bottom: 16), child: SettingsGroup( children: [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + SettingsItem( + icon: Icons.album_outlined, + title: sheetContext.l10n.homeGoToAlbum, + onTap: () => Navigator.of(sheetContext).pop('album'), + ), SettingsItem( icon: Icons.info_outline, title: sheetContext.l10n.nowPlayingDetails, @@ -751,6 +761,9 @@ class _NowPlayingScreenState extends ConsumerState { ); if (!mounted) return; switch (action) { + case 'album': + await _goToCurrentAlbum(mediaItem: mediaItem, source: source); + break; case 'details': _showDetailsSheet(colorScheme); break; @@ -760,6 +773,80 @@ class _NowPlayingScreenState extends ConsumerState { } } + Future _goToCurrentAlbum({ + required MediaItem mediaItem, + required String source, + }) async { + final albumName = (mediaItem.album ?? '').trim(); + if (albumName.isEmpty) return; + + // Prefer the stored collection so this action remains useful offline and + // opens the exact files the user is currently playing. + try { + final historyItem = source.isEmpty + ? null + : await ref + .read(downloadHistoryProvider.notifier) + .getByFilePathAsync(source); + if (!mounted) return; + if (historyItem != null) { + final albumArtist = (historyItem.albumArtist ?? '').trim(); + pushViaPreferredNavigator( + context, + (_) => DownloadedAlbumScreen( + albumName: historyItem.albumName, + artistName: albumArtist.isNotEmpty + ? albumArtist + : historyItem.artistName, + coverUrl: historyItem.coverUrl, + ), + ); + return; + } + } catch (e) { + _log.w('Failed to resolve downloaded album: $e'); + } + + try { + final row = await LibraryDatabase.instance.getById(mediaItem.id); + if (!mounted) return; + if (row != null) { + final item = LocalLibraryItem.fromJson(row); + final rows = await LibraryDatabase.instance + .getQueueLocalAlbumTracksByKey(item.albumKey); + if (!mounted) return; + final tracks = rows + .map(LocalLibraryItem.fromJson) + .toList(growable: false); + if (tracks.isNotEmpty) { + final albumArtist = (item.albumArtist ?? '').trim(); + pushViaPreferredNavigator( + context, + (_) => LocalAlbumScreen( + albumName: item.albumName, + artistName: albumArtist.isNotEmpty + ? albumArtist + : item.artistName, + coverPath: item.coverPath, + tracks: tracks, + ), + ); + return; + } + } + } catch (e) { + _log.w('Failed to resolve local album: $e'); + } + + if (!mounted) return; + await navigateToAlbum( + context, + albumName: albumName, + artistName: mediaItem.artist, + coverUrl: mediaItem.artUri?.toString(), + ); + } + Future _shuffleLibrary(MusicPlayerController controller) async { try { final rows = await LibraryDatabase.instance.getAll(); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 2ffec8fc..1bd32d98 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -45,6 +45,9 @@ import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/local_album_screen.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; part 'track_metadata_screen_cover.dart'; part 'track_metadata_screen_display.dart'; diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart index ee755ae1..2c2af811 100644 --- a/lib/screens/track_metadata_screen_menu.dart +++ b/lib/screens/track_metadata_screen_menu.dart @@ -29,6 +29,12 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { label: l10n.trackAddToQueue, onTap: () => _enqueueThis(ref, playNext: false), ), + if (albumName.trim().isNotEmpty) + _MetadataOption( + icon: Icons.album_outlined, + label: l10n.homeGoToAlbum, + onTap: () => _goToStoredAlbum(screenContext), + ), _MetadataOption( icon: Icons.copy_outlined, label: l10n.trackCopyFilePath, @@ -226,4 +232,55 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { return placeholder(); } + + Future _goToStoredAlbum(BuildContext screenContext) async { + final resolvedAlbumArtist = (albumArtist ?? '').trim(); + final artist = resolvedAlbumArtist.isNotEmpty + ? resolvedAlbumArtist + : artistName; + + if (!_isLocalItem) { + pushViaPreferredNavigator( + screenContext, + (_) => DownloadedAlbumScreen( + albumName: albumName, + artistName: artist, + coverUrl: _coverUrl, + ), + ); + return; + } + + try { + final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey( + _localLibraryItem!.albumKey, + ); + if (!mounted || !screenContext.mounted) return; + final tracks = rows + .map(LocalLibraryItem.fromJson) + .toList(growable: false); + if (tracks.isNotEmpty) { + pushViaPreferredNavigator( + screenContext, + (_) => LocalAlbumScreen( + albumName: albumName, + artistName: artist, + coverPath: _localCoverPath, + tracks: tracks, + ), + ); + return; + } + } catch (e) { + _log.w('Failed to resolve local album: $e'); + } + + if (!mounted || !screenContext.mounted) return; + await navigateToAlbum( + screenContext, + albumName: albumName, + artistName: artist, + coverUrl: _coverUrl, + ); + } } diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 1d3ca921..fcf88c09 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -292,7 +292,7 @@ void _pushArtistScreen( final isExtension = extensionId != null; final resolvedProviderId = extensionId; - _pushViaPreferredNavigator( + pushViaPreferredNavigator( context, (context) => isExtension && resolvedProviderId != null ? ExtensionArtistScreen( @@ -320,7 +320,7 @@ void _pushAlbumScreen( final isExtension = extensionId != null; final resolvedExtensionId = extensionId; - _pushViaPreferredNavigator( + pushViaPreferredNavigator( context, (context) => isExtension && resolvedExtensionId != null ? ExtensionAlbumScreen( @@ -339,7 +339,10 @@ void _pushAlbumScreen( ); } -void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { +/// Pushes detail pages into the active tab navigator when invoked from a root +/// modal such as Now Playing. The root modal is dismissed first so the album +/// is immediately visible instead of being hidden behind it. +void pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { final currentNavigator = Navigator.of(context); final rootNavigator = Navigator.of(context, rootNavigator: true); final activeTabNavigator = ShellNavigationService.activeTabNavigator(); diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 30502198..58587927 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -194,6 +194,13 @@ 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: isLoved ? Icons.favorite : Icons.favorite_border, iconColor: isLoved ? colorScheme.error : null, @@ -269,6 +276,23 @@ class _TrackOptionsSheet extends ConsumerWidget { downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true); } + Future _goToAlbum(BuildContext context) async { + final navigationContext = Navigator.of( + 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, + ); + } + Future _playLocal(BuildContext context, WidgetRef ref) async { final rootContext = Navigator.of(context, rootNavigator: true).context; Navigator.pop(context); diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 6f672737..b45c90c9 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -43,32 +43,37 @@ void main() { id: id, title: id == 'first' ? 'First' : 'Second', artist: 'Artist', + album: 'Album', duration: const Duration(minutes: 3), extras: {'source': 'content://library/$id.flac'}, ); + Future pumpNowPlaying(WidgetTester tester) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), + playbackStateProvider.overrideWith((ref) => const Stream.empty()), + playQueueProvider.overrideWith((ref) => const Stream.empty()), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: NowPlayingScreen(), + ), + ), + ); + } + testWidgets( 'automatic SAF track change refreshes lyrics while Lyrics page is active', (tester) async { - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), - playbackStateProvider.overrideWith((ref) => const Stream.empty()), - playQueueProvider.overrideWith((ref) => const Stream.empty()), - ], - child: const MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: NowPlayingScreen(), - ), - ), - ); + await pumpNowPlaying(tester); mediaItems.add(item('first')); await tester.pumpAndSettle(); @@ -83,4 +88,18 @@ void main() { expect(find.text('First lyric'), findsNothing); }, ); + + testWidgets('Now Playing menu exposes Go to Album when album is known', ( + tester, + ) async { + await pumpNowPlaying(tester); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + 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); + }); } diff --git a/test/preview_button_layout_test.dart b/test/preview_button_layout_test.dart index 2dd06825..467d7c8e 100644 --- a/test/preview_button_layout_test.dart +++ b/test/preview_button_layout_test.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -84,4 +87,40 @@ void main() { expectCenteredHitbox(tester, Icons.more_vert); }); + + testWidgets('track options expose Go to Album for album tracks', ( + 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: track)), + ), + ), + ); + + 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 { + @override + AppSettings build() => const AppSettings(); +} + +class _TestLibraryCollectionsNotifier extends LibraryCollectionsNotifier { + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); }