feat(navigation): add go to album actions

This commit is contained in:
zarzet
2026-08-01 16:06:08 +07:00
parent 6a3a85f7c9
commit 645a324a5f
7 changed files with 254 additions and 22 deletions
+87
View File
@@ -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<NowPlayingScreen> {
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<NowPlayingScreen> {
);
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<NowPlayingScreen> {
}
}
Future<void> _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<void> _shuffleLibrary(MusicPlayerController controller) async {
try {
final rows = await LibraryDatabase.instance.getAll();
+3
View File
@@ -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';
@@ -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<void> _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,
);
}
}
+6 -3
View File
@@ -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();
@@ -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<void> _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<void> _playLocal(BuildContext context, WidgetRef ref) async {
final rootContext = Navigator.of(context, rootNavigator: true).context;
Navigator.pop(context);
+38 -19
View File
@@ -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<void> 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);
});
}
+39
View File
@@ -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);
}