mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(navigation): add go to album actions
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user