From a93e552b3e68523ab739f06ed4113bb88096504a Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:11:37 +0700 Subject: [PATCH] feat(library): add batch playlist and queue actions Expose playlist, play-next and queue actions across Library search and album selections, including selected artist albums. Sort playlists, keep long selection toolbars scrollable, separate file operations in metadata menus, and show related local songs without online playback. --- lib/l10n/arb/app_en.arb | 5 + lib/l10n/arb/app_id.arb | 7 +- lib/providers/music_player_provider.dart | 14 + lib/screens/album_screen.dart | 16 + lib/screens/artist_screen.dart | 82 +++-- lib/screens/downloaded_album_screen.dart | 9 + lib/screens/local_album_screen.dart | 9 + lib/screens/queue_tab.dart | 1 + lib/screens/queue_tab_filter_widgets.dart | 14 + lib/screens/queue_tab_item_widgets.dart | 4 + .../related_library_tracks_screen.dart | 107 ++++++ lib/screens/track_metadata_screen.dart | 4 +- lib/screens/track_metadata_screen_menu.dart | 305 +++++++++--------- lib/services/related_library_tracks.dart | 108 +++++++ lib/widgets/library_search_results.dart | 5 + .../library_selection_playback_actions.dart | 70 ++++ lib/widgets/library_track_selection_bar.dart | 13 +- lib/widgets/selection_bottom_bar.dart | 10 +- ...brary_selection_playback_actions_test.dart | 93 ++++++ test/related_library_tracks_test.dart | 102 ++++++ test/track_metadata_cover_search_test.dart | 3 + test/track_metadata_replaygain_test.dart | 4 + 22 files changed, 797 insertions(+), 188 deletions(-) create mode 100644 lib/screens/related_library_tracks_screen.dart create mode 100644 lib/services/related_library_tracks.dart create mode 100644 lib/widgets/library_selection_playback_actions.dart create mode 100644 test/library_selection_playback_actions_test.dart create mode 100644 test/related_library_tracks_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ee9ffeb0..96df59d0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7033,6 +7033,11 @@ "artistUpcomingConcerts": "Upcoming Concerts", "artistAllUpcomingConcerts": "All Upcoming Concerts", "artistNoUpcomingConcerts": "No upcoming concerts.", + "trackEditAudioFile": "Edit audio file", + "relatedLibraryTracks": "Similar songs in Library", + "relatedLibraryDescription": "Songs you already have with related artist, album or genre tags. No online music is searched.", + "relatedLibraryEmpty": "No related songs found in your Library.", + "relatedLibraryLoadError": "Could not find related songs. Try again.", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index e270d5f5..ee0d0c04 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6951,5 +6951,10 @@ "hiResCheckSampledExplanation": "Berdasarkan {seconds} detik audio. Analisis ini tidak dapat memastikan keaslian master sumber.", "hiResCheckImagingEvidence": "Spektrum memiliki pola pita tercermin yang konsisten dengan upsampling.", "hiResCheckRateEvidence": "Penurunan tajam di sekitar {cutoff} dan noise floor yang terukur mengindikasikan sumber dengan sample rate lebih rendah.", - "hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise." + "hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise.", + "trackEditAudioFile": "Ubah file audio", + "relatedLibraryTracks": "Lagu serupa di Library", + "relatedLibraryDescription": "Lagu yang sudah Anda miliki dengan tag artis, album, atau genre terkait. Tidak mencari musik online.", + "relatedLibraryEmpty": "Tidak ada lagu terkait di Library Anda.", + "relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi." } diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index f907b755..00f8bfaa 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -199,6 +199,20 @@ class MusicPlayerController { Future addToQueue(PlayableMedia item) async => (await ensureInitialized())?.enqueue(item); + Future enqueueLibraryItems( + List items, { + bool playNext = false, + }) async { + final media = [ + for (final item in items) + if (item.localItem != null) + playableFromLocal(item.localItem!) + else if (item.historyItem != null) + playableFromHistory(item.historyItem!), + ]; + await (await ensureInitialized())?.enqueueAll(media, playNext: playNext); + } + Future playNextHistory(DownloadHistoryItem item) async => playNext(playableFromHistory(item)); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index daed8471..97d5acf3 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -798,6 +798,22 @@ class _AlbumScreenState extends ConsumerState }, bottomPadding: bottomPadding, children: [ + SizedBox( + width: double.infinity, + child: SelectionActionButton( + icon: Icons.playlist_add, + label: barContext.l10n.collectionAddToPlaylist, + colorScheme: colorScheme, + onPressed: selectedCount == 0 + ? null + : () { + final selected = _selectedTracks(tracks); + exitSelectionMode(); + showAddTracksToPlaylistSheet(context, ref, selected); + }, + ), + ), + const SizedBox(height: 8), SizedBox( width: double.infinity, child: SelectionActionButton( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index fe369fd5..9f02ca82 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -42,6 +44,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; @@ -170,6 +173,7 @@ class _ArtistScreenState extends ConsumerState int _popularCurrentPage = 0; bool _isFetchingDiscography = false; + int _discographyFetchGeneration = 0; List? _albumBucketSource; List _albumsOnlyBucket = const []; List _singlesBucket = const []; @@ -849,6 +853,25 @@ class _ArtistScreenState extends ConsumerState ? context.l10n.tracksCount(totalTracks) : context.l10n.discographySelectAlbumsSubtitle, children: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 && !_isFetchingDiscography + ? () { + exitSelectionMode(); + _fetchAndQueueAlbums( + selectedAlbums, + ref.read(settingsProvider).defaultService, + null, + addToPlaylist: true, + ); + } + : null, + icon: const Icon(Icons.playlist_add), + label: Text(context.l10n.collectionAddToPlaylist), + ), + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -1045,29 +1068,36 @@ class _ArtistScreenState extends ConsumerState Future _fetchAndQueueAlbums( List albums, String service, - String? qualityOverride, - ) async { - if (_isFetchingDiscography) return; + String? qualityOverride, { + bool addToPlaylist = false, + }) async { + if (!mounted || _isFetchingDiscography) return; + final generation = ++_discographyFetchGeneration; setState(() => _isFetchingDiscography = true); - if (!mounted) { - setState(() => _isFetchingDiscography = false); - return; - } - final progressDialogKey = GlobalKey<_FetchingProgressDialogState>(); - showAppDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _FetchingProgressDialog( - key: progressDialogKey, - totalAlbums: albums.length, - onCancel: () { + unawaited( + showAppDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _FetchingProgressDialog( + key: progressDialogKey, + totalAlbums: albums.length, + onCancel: () { + _discographyFetchGeneration++; + setState(() => _isFetchingDiscography = false); + Navigator.pop(ctx); + }, + ), + ).whenComplete(() { + if (mounted && + generation == _discographyFetchGeneration && + _isFetchingDiscography) { + _discographyFetchGeneration++; setState(() => _isFetchingDiscography = false); - Navigator.pop(ctx); - }, - ), + } + }), ); final allTracks = []; @@ -1075,14 +1105,20 @@ class _ArtistScreenState extends ConsumerState int failedCount = 0; for (final album in albums) { - if (!_isFetchingDiscography) break; + if (!mounted || + generation != _discographyFetchGeneration || + !_isFetchingDiscography) { + break; + } try { final tracks = await _fetchAlbumTracks(album); + if (!mounted || generation != _discographyFetchGeneration) return; allTracks.addAll(tracks); } catch (e) { failedCount++; } + if (!mounted || generation != _discographyFetchGeneration) return; fetchedCount++; @@ -1094,7 +1130,10 @@ class _ArtistScreenState extends ConsumerState } } + if (!mounted || generation != _discographyFetchGeneration) return; + final cancelled = !_isFetchingDiscography; setState(() => _isFetchingDiscography = false); + if (cancelled) return; if (mounted) { Navigator.of(context, rootNavigator: true).pop(); @@ -1115,6 +1154,11 @@ class _ArtistScreenState extends ConsumerState return; } + if (addToPlaylist) { + await showAddTracksToPlaylistSheet(context, ref, allTracks); + return; + } + final settings = ref.read(settingsProvider); final skipExisting = settings.deduplicateDownloads && !settings.allowQualityVariants; diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 7f2815b5..f47116d7 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -34,6 +34,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; @@ -706,6 +707,14 @@ class _DownloadedAlbumScreenState extends ConsumerState }, bottomPadding: bottomPadding, children: [ + LibrarySelectionPlaybackActions( + items: _selectedUnifiedItems(tracks), + onClose: exitSelectionMode, + ), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(context.l10n.trackEditMetadata), + ), LayoutBuilder( builder: (context, constraints) { const spacing = 8.0; diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e35bf843..c7f06d97 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; @@ -533,6 +534,14 @@ class _LocalAlbumScreenState extends ConsumerState }, bottomPadding: bottomPadding, children: [ + LibrarySelectionPlaybackActions( + items: _selectedUnifiedItems(tracks), + onClose: exitSelectionMode, + ), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(context.l10n.trackEditMetadata), + ), LayoutBuilder( builder: (context, constraints) { const spacing = 8.0; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0311b0e2..0f3a6762 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/library_search_results.dart'; import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; +import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; import 'package:spotiflac_android/widgets/library_playlist_drag_source.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 1243bda2..11495f88 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -235,6 +235,20 @@ extension _QueueTabFilterWidgets on _QueueTabState { playlistIndexes.add(i); } } + playlistIndexes.sort((a, b) { + final first = collectionState.playlists[a]; + final second = collectionState.playlists[b]; + return switch (_sortMode) { + 'a-z' => first.name.toLowerCase().compareTo( + second.name.toLowerCase(), + ), + 'z-a' => second.name.toLowerCase().compareTo( + first.name.toLowerCase(), + ), + 'oldest' => first.createdAt.compareTo(second.createdAt), + _ => second.updatedAt.compareTo(first.updatedAt), + }; + }); } Widget leadGridCell(int index) { diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 91caf9bb..a8572e81 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -13,6 +13,10 @@ extension _QueueTabItemWidgets on _QueueTabState { final localOnlySelection = _isLocalOnlySelection(unifiedItems); return LibraryTrackSelectionBar( + playbackActions: LibrarySelectionPlaybackActions( + items: _selectedItemsFromAll(unifiedItems), + onClose: _exitSelectionMode, + ), selectedCount: selectedCount, allSelected: allSelected, onClose: _exitSelectionMode, diff --git a/lib/screens/related_library_tracks_screen.dart b/lib/screens/related_library_tracks_screen.dart new file mode 100644 index 00000000..e1794ff1 --- /dev/null +++ b/lib/screens/related_library_tracks_screen.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/related_library_tracks.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +final relatedLibraryTracksProvider = FutureProvider.autoDispose + .family, UnifiedLibraryItem>((ref, seed) { + final includeLocal = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + return loadRelatedLibraryTracks(seed, includeLocal: includeLocal); + }); + +class RelatedLibraryTracksScreen extends ConsumerWidget { + const RelatedLibraryTracksScreen({super.key, required this.seed}); + + final UnifiedLibraryItem seed; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracks = ref.watch(relatedLibraryTracksProvider(seed)); + return Scaffold( + body: CustomScrollView( + slivers: [ + AppSliverHeader.page(title: context.l10n.relatedLibraryTracks), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(context.l10n.relatedLibraryDescription), + ), + ), + ...tracks.when( + loading: () => [ + const SliverToBoxAdapter( + child: Center(child: AppLoadingIndicator()), + ), + ], + error: (_, _) => [ + SliverToBoxAdapter( + child: ListTile( + title: Text(context.l10n.relatedLibraryLoadError), + trailing: IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => + ref.invalidate(relatedLibraryTracksProvider(seed)), + ), + ), + ), + ], + data: (items) => [ + if (items.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(context.l10n.relatedLibraryEmpty), + ), + ), + SliverList.list( + children: [ + for (final item in items) + ListTile( + leading: CachedCoverImage( + imageUrl: item.localCoverPath ?? item.coverUrl ?? '', + width: 48, + height: 48, + borderRadius: BorderRadius.circular(8), + ), + title: Text(item.trackName), + subtitle: Text(item.artistName), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TrackMetadataScreen( + item: item.historyItem, + localItem: item.localItem, + ), + ), + ), + trailing: IconButton( + tooltip: context.l10n.trackMetadataPlay, + icon: const Icon(Icons.play_arrow), + onPressed: () => + ref.read(musicPlayerControllerProvider).playAll([ + if (item.localItem != null) + playableFromLocal(item.localItem!) + else if (item.historyItem != null) + playableFromHistory(item.historyItem!), + ]), + ), + ), + ], + ), + ], + ), + const NavBarSliverSpacer(), + ], + ), + ); + } +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index a6246958..c88aae19 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -76,8 +76,8 @@ import 'package:spotiflac_android/widgets/metadata_barcode.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/screens/related_library_tracks_screen.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; part 'track_metadata_screen_cover.dart'; diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart index 3ea3960d..b28f88e9 100644 --- a/lib/screens/track_metadata_screen_menu.dart +++ b/lib/screens/track_metadata_screen_menu.dart @@ -8,114 +8,154 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { WidgetRef ref, ColorScheme colorScheme, { Rect? anchor, + bool fileActionsOnly = false, }) { Widget buildMenu(BuildContext sheetContext) { final l10n = sheetContext.l10n; final options = <_MetadataOption>[ - if (_fileExists) + if (!fileActionsOnly) ...[ + if (_fileExists) + _MetadataOption( + icon: Icons.playlist_play, + label: l10n.trackPlayNext, + onTap: () => _enqueueThis(ref, playNext: true), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.queue_music, + label: l10n.trackAddToQueue, + onTap: () => _enqueueThis(ref, playNext: false), + ), + if (albumName.trim().isNotEmpty) + _MetadataOption( + icon: Icons.album_outlined, + label: l10n.homeGoToAlbum, + onTap: () => navigateToAlbum( + screenContext, + albumName: albumName, + artistName: albumArtist ?? artistName, + coverUrl: _coverUrl, + ), + ), _MetadataOption( - icon: Icons.playlist_play, - label: l10n.trackPlayNext, - onTap: () => _enqueueThis(ref, playNext: true), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.queue_music, - 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, - dividerAbove: _fileExists, - onTap: () => _copyToClipboard(screenContext, cleanFilePath), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.edit_outlined, - label: l10n.trackEditMetadata, + icon: Icons.person_outline, + label: l10n.mornyeGoToArtist, onTap: () => - _showEditMetadataSheet(screenContext, ref, colorScheme), + navigateToArtistCredits(screenContext, artistNames: artistName), ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) _MetadataOption( - icon: Icons.image_outlined, - label: l10n.trackSaveCoverArt, - onTap: _saveCoverArt, - ), - if (!_isLocalItem) - _MetadataOption( - icon: Icons.lyrics_outlined, - label: l10n.trackSaveLyrics, - onTap: _saveLyrics, - ), - if (_fileExists) - _MetadataOption( - icon: Icons.travel_explore, - label: l10n.trackReEnrich, - onTap: _reEnrichMetadata, - ), - if (_fileExists && _isConvertibleFormat) - _MetadataOption( - icon: Icons.swap_horiz, - label: l10n.trackConvertFormat, - onTap: () => _showConvertSheet(screenContext), - ), - if (_fileExists && !_isCueFile) - _MetadataOption( - icon: Icons.graphic_eq, - label: l10n.trackReplayGain, - onTap: () => _updateReplayGain(), - ), - if (_fileExists && !_isCueFile) - _MetadataOption( - icon: Icons.remove_circle_outline, - label: l10n.trackRemoveReplayGain, - onTap: () => _removeReplayGain(), - ), - if (_fileExists && _isCueFile) - _MetadataOption( - icon: Icons.call_split, - label: l10n.cueSplitTitle, - onTap: () => _showCueSplitSheet(screenContext), - ), - if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) - _MetadataOption( - icon: Icons.open_in_new, - label: l10n.trackOpenOn, - dividerAbove: true, - onTap: () => OpenOnPlatformSheet.show( + icon: Icons.tune, + label: l10n.trackEditAudioFile, + onTap: () => _showOptionsMenu( screenContext, - spotifyId: _spotifyId ?? '', - isrc: isrc ?? '', + ref, + colorScheme, + fileActionsOnly: true, ), ), - _MetadataOption( - icon: Icons.share_outlined, - label: l10n.trackMetadataShare, - dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), - onTap: () => _shareFile(screenContext), - ), - _MetadataOption( - icon: Icons.delete_outline, - label: l10n.trackRemoveFromDevice, - destructive: true, - onTap: () => _confirmDelete(screenContext, ref, colorScheme), - ), + _MetadataOption( + icon: Icons.library_music_outlined, + label: l10n.relatedLibraryTracks, + onTap: () => Navigator.of(screenContext).push( + MaterialPageRoute( + builder: (_) => RelatedLibraryTracksScreen( + seed: widget.localItem != null + ? UnifiedLibraryItem.fromLocalLibrary(widget.localItem!) + : UnifiedLibraryItem.fromDownloadHistory(widget.item!), + ), + ), + ), + ), + ], + if (fileActionsOnly) ...[ + _MetadataOption( + icon: Icons.copy_outlined, + label: l10n.trackCopyFilePath, + dividerAbove: _fileExists, + onTap: () => _copyToClipboard(screenContext, cleanFilePath), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.edit_outlined, + label: l10n.trackEditMetadata, + onTap: () => + _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + _MetadataOption( + icon: Icons.image_outlined, + label: l10n.trackSaveCoverArt, + onTap: _saveCoverArt, + ), + if (!_isLocalItem) + _MetadataOption( + icon: Icons.lyrics_outlined, + label: l10n.trackSaveLyrics, + onTap: _saveLyrics, + ), + if (_fileExists) + _MetadataOption( + icon: Icons.travel_explore, + label: l10n.trackReEnrich, + onTap: _reEnrichMetadata, + ), + if (_fileExists && _isConvertibleFormat) + _MetadataOption( + icon: Icons.swap_horiz, + label: l10n.trackConvertFormat, + onTap: () => _showConvertSheet(screenContext), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.graphic_eq, + label: l10n.trackReplayGain, + onTap: () => _updateReplayGain(), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.remove_circle_outline, + label: l10n.trackRemoveReplayGain, + onTap: () => _removeReplayGain(), + ), + if (_fileExists && _isCueFile) + _MetadataOption( + icon: Icons.call_split, + label: l10n.cueSplitTitle, + onTap: () => _showCueSplitSheet(screenContext), + ), + ], + if (!fileActionsOnly) ...[ + if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) + _MetadataOption( + icon: Icons.open_in_new, + label: l10n.trackOpenOn, + dividerAbove: true, + onTap: () => OpenOnPlatformSheet.show( + screenContext, + spotifyId: _spotifyId ?? '', + isrc: isrc ?? '', + ), + ), + _MetadataOption( + icon: Icons.share_outlined, + label: l10n.trackMetadataShare, + dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), + onTap: () => _shareFile(screenContext), + ), + _MetadataOption( + icon: Icons.delete_outline, + label: l10n.trackRemoveFromDevice, + destructive: true, + onTap: () => _confirmDelete(screenContext, ref, colorScheme), + ), + ], ]; if (sheetContext.isMornye) { final groups = >[]; for (final option in options) { - if (option.icon == Icons.share_outlined || - option.icon == Icons.edit_outlined) { + if (option.icon == Icons.share_outlined) { continue; } if (groups.isEmpty || option.dividerAbove || option.destructive) { @@ -133,32 +173,26 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState { } return MornyeContextMenu( quickActions: [ - if (_fileExists) ...[ - MornyeMenuAction( - icon: CupertinoIcons.play_fill, - label: l10n.trackMetadataPlay, - onPressed: () => _closeOptionsMenuAndRun( - sheetContext, - () => _openFile(screenContext, rawFilePath), + if (!fileActionsOnly) ...[ + if (_fileExists) ...[ + MornyeMenuAction( + icon: CupertinoIcons.play_fill, + label: l10n.trackMetadataPlay, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _openFile(screenContext, rawFilePath), + ), ), - ), + ], MornyeMenuAction( - icon: CupertinoIcons.pencil, - label: l10n.trackEditMetadata, + icon: CupertinoIcons.share_solid, + label: l10n.trackMetadataShare, onPressed: () => _closeOptionsMenuAndRun( sheetContext, - () => _showEditMetadataSheet(screenContext, ref, colorScheme), + () => _shareFile(screenContext), ), ), ], - MornyeMenuAction( - icon: CupertinoIcons.share_solid, - label: l10n.trackMetadataShare, - onPressed: () => _closeOptionsMenuAndRun( - sheetContext, - () => _shareFile(screenContext), - ), - ), ], groups: groups, ); @@ -303,55 +337,4 @@ 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/services/related_library_tracks.dart b/lib/services/related_library_tracks.dart new file mode 100644 index 00000000..9aca227e --- /dev/null +++ b/lib/services/related_library_tracks.dart @@ -0,0 +1,108 @@ +import 'dart:math'; + +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +String _normalized(String? value) => (value ?? '').trim().toLowerCase(); + +/// Only related, locally stored songs qualify. A sparse library can return +/// an empty result instead of silently looking up online tracks. +List rankRelatedLibraryTracks( + UnifiedLibraryItem seed, + List candidates, +) { + final artists = splitArtistNames(seed.artistName).map(_normalized).toSet(); + final seenPaths = {seed.filePath}; + final seenSongs = { + '${_normalized(seed.artistName)}|${_normalized(seed.trackName)}', + }; + final scored = <(UnifiedLibraryItem, int)>[]; + for (final item in candidates) { + final path = item.filePath.trim(); + if (path.isEmpty || + path.startsWith('http://') || + path.startsWith('https://')) { + continue; + } + final sameArtist = splitArtistNames( + item.artistName, + ).map(_normalized).any(artists.contains); + final sameGenre = + _normalized(seed.genre).isNotEmpty && + _normalized(seed.genre) == _normalized(item.genre); + final sameAlbum = + sameArtist && + _normalized(seed.albumName).isNotEmpty && + _normalized(seed.albumName) == _normalized(item.albumName); + final score = + (sameArtist ? 5 : 0) + (sameGenre ? 4 : 0) + (sameAlbum ? 3 : 0); + if (score == 0 || !seenPaths.add(path)) continue; + final song = + '${_normalized(item.artistName)}|${_normalized(item.trackName)}'; + if (!seenSongs.add(song)) continue; + scored.add((item, score)); + } + scored.sort((a, b) { + final score = b.$2.compareTo(a.$2); + return score != 0 ? score : a.$1.trackName.compareTo(b.$1.trackName); + }); + return scored.map((entry) => entry.$1).toList(growable: false); +} + +Future> loadRelatedLibraryTracks( + UnifiedLibraryItem seed, { + required bool includeLocal, +}) async { + final database = LibraryDatabase.instance; + final count = (await database.getQueueCounts( + QueueLibraryDbQuery(includeLocal: includeLocal), + )).allTrackCount; + final rows = >[]; + for (final artist in splitArtistNames(seed.artistName).take(4)) { + rows.addAll( + await database.getQueueTrackPage( + QueueLibraryDbQuery( + includeLocal: includeLocal, + searchQuery: artist, + limit: 96, + ), + ), + ); + } + rows.addAll( + await database.getQueueTrackPage( + QueueLibraryDbQuery( + includeLocal: includeLocal, + offset: count > 256 ? Random().nextInt(count - 255) : 0, + limit: 256, + ), + ), + ); + final candidates = []; + for (final row in rows) { + final json = Map.from(row['item'] as Map); + if (row['source'] == 'local') { + candidates.add( + UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem.fromJson(json)), + ); + } else if (row['source'] == 'downloaded') { + candidates.add( + UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem.fromJson(json), + ), + ); + } + } + // Bound storage-provider probes as well as database pages. + final ranked = rankRelatedLibraryTracks(seed, candidates).take(48).toList(); + final exists = await fileExistenceByPath( + ranked.map((item) => item.filePath).toList(), + ); + return ranked + .where((item) => exists[item.filePath] == true) + .take(24) + .toList(growable: false); +} diff --git a/lib/widgets/library_search_results.dart b/lib/widgets/library_search_results.dart index be8d68de..df84446e 100644 --- a/lib/widgets/library_search_results.dart +++ b/lib/widgets/library_search_results.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; +import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -156,6 +157,10 @@ class _LibrarySearchResultsState extends ConsumerState { .toList(growable: false) : const []; return LibraryTrackSelectionBar( + playbackActions: LibrarySelectionPlaybackActions( + items: items, + onClose: _exitSelection, + ), selectedCount: items.length, allSelected: allSelected, onClose: _exitSelection, diff --git a/lib/widgets/library_selection_playback_actions.dart b/lib/widgets/library_selection_playback_actions.dart new file mode 100644 index 00000000..aeabd002 --- /dev/null +++ b/lib/widgets/library_selection_playback_actions.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; + +/// Collection actions stay separate from actions that modify audio files. +class LibrarySelectionPlaybackActions extends ConsumerWidget { + const LibrarySelectionPlaybackActions({ + super.key, + required this.items, + required this.onClose, + }); + + final List items; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final actions = [ + SelectionActionButton( + icon: Icons.playlist_add, + label: context.l10n.collectionAddToPlaylist, + colorScheme: colors, + onPressed: items.isEmpty + ? null + : () { + final tracks = items.map((item) => item.toTrack()).toList(); + final navigatorContext = Navigator.of( + context, + rootNavigator: true, + ).context; + onClose(); + showAddTracksToPlaylistSheet(navigatorContext, ref, tracks); + }, + ), + for (final next in [true, false]) + SelectionActionButton( + icon: next ? Icons.playlist_play : Icons.queue_music, + label: next + ? context.l10n.trackPlayNext + : context.l10n.trackAddToQueue, + colorScheme: colors, + onPressed: items.isEmpty + ? null + : () async { + final controller = ref.read(musicPlayerControllerProvider); + await controller.enqueueLibraryItems(items, playNext: next); + if (context.mounted) onClose(); + }, + ), + ]; + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final action in actions) + SizedBox(width: (constraints.maxWidth - 8) / 2, child: action), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/library_track_selection_bar.dart b/lib/widgets/library_track_selection_bar.dart index 303b38f9..4967c057 100644 --- a/lib/widgets/library_track_selection_bar.dart +++ b/lib/widgets/library_track_selection_bar.dart @@ -4,9 +4,8 @@ import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; -/// Multi-select toolbar for Library tracks: FLAC upgrade (local selections -/// only), re-enrich, convert, ReplayGain add/remove and delete. Shared by the -/// Library list and Library search results so both expose the same actions. +/// Shared Library toolbar for playlist/queue actions and audio file operations. +/// FLAC upgrade applies only to eligible local selections. class LibraryTrackSelectionBar extends StatelessWidget { const LibraryTrackSelectionBar({ super.key, @@ -21,6 +20,7 @@ class LibraryTrackSelectionBar extends StatelessWidget { required this.onDelete, this.flacEligibleCount = 0, this.onQueueFlac, + this.playbackActions, }); final int selectedCount; @@ -36,6 +36,7 @@ class LibraryTrackSelectionBar extends StatelessWidget { /// FLAC upgrade is offered when this is positive and [onQueueFlac] is set. final int flacEligibleCount; final VoidCallback? onQueueFlac; + final Widget? playbackActions; @override Widget build(BuildContext context) { @@ -48,6 +49,12 @@ class LibraryTrackSelectionBar extends StatelessWidget { onToggleSelectAll: onToggleSelectAll, bottomPadding: bottomPadding, children: [ + ?playbackActions, + if (playbackActions != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(context.l10n.trackEditMetadata), + ), LayoutBuilder( builder: (context, constraints) { const spacing = 8.0; diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 770c6220..bd3a342e 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -347,8 +347,14 @@ class SelectionBottomBar extends StatelessWidget { ), ), ); + final boundedContent = ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: SingleChildScrollView(child: content), + ); if (context.isMornye) { - return MornyeGlassPanel.overlay(child: content); + return MornyeGlassPanel.overlay(child: boundedContent); } return Container( decoration: BoxDecoration( @@ -364,7 +370,7 @@ class SelectionBottomBar extends StatelessWidget { ), ], ), - child: content, + child: boundedContent, ); } } diff --git a/test/library_selection_playback_actions_test.dart b/test/library_selection_playback_actions_test.dart new file mode 100644 index 00000000..24426f96 --- /dev/null +++ b/test/library_selection_playback_actions_test.dart @@ -0,0 +1,93 @@ +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/unified_library_item.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/library_selection_playback_actions.dart'; +import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; + +class _Controller extends MusicPlayerController { + List enqueued = []; + bool insertedNext = false; + + @override + Future enqueueLibraryItems( + List items, { + bool playNext = false, + }) async { + enqueued = items.map((item) => item.id).toList(); + insertedNext = playNext; + } +} + +void main() { + for (final mornye in [false, true]) { + for (final size in [const Size(390, 844), const Size(844, 390)]) { + testWidgets('batch playback preserves selection order ($mornye, $size)', ( + tester, + ) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = _Controller(); + var closed = false; + final items = [ + for (final id in ['second', 'first']) + UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: id, + trackName: id, + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/$id.flac', + scannedAt: DateTime(2026), + ), + ), + ]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + musicPlayerControllerProvider.overrideWithValue(controller), + ], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: LibraryTrackSelectionBar( + selectedCount: 2, + allSelected: true, + onClose: () => closed = true, + onToggleSelectAll: () {}, + bottomPadding: 0, + onReEnrich: () {}, + onConvert: () {}, + onReplayGain: ({required remove}) {}, + onDelete: () {}, + playbackActions: LibrarySelectionPlaybackActions( + items: items, + onClose: () => closed = true, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Play next')); + await tester.pumpAndSettle(); + expect(controller.enqueued, items.map((item) => item.id)); + expect(controller.insertedNext, isTrue); + expect(closed, isTrue); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/test/related_library_tracks_test.dart b/test/related_library_tracks_test.dart new file mode 100644 index 00000000..5c0e60eb --- /dev/null +++ b/test/related_library_tracks_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/related_library_tracks.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; + +UnifiedLibraryItem _track( + String id, { + String artist = 'First Artist', + String album = 'Album', + String? genre, + String? path, +}) => UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: id, + trackName: id, + artistName: artist, + albumName: album, + genre: genre, + filePath: path ?? '/$id.flac', + scannedAt: DateTime(2026), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + test( + 'related Library songs rank credits, deduplicate and exclude remote files', + () { + final seed = _track( + 'seed', + artist: 'First Artist feat. Second Artist', + genre: 'Rock', + ); + final album = _track('album', genre: 'Rock'); + final genre = _track( + 'genre', + artist: 'Other', + album: 'Other Album', + genre: 'Rock', + ); + final collaborator = _track( + 'collaborator', + artist: 'Second Artist', + album: 'Other Album', + ); + final results = rankRelatedLibraryTracks(seed, [ + seed, + album, + album, + genre, + collaborator, + _track('unrelated', artist: 'Other', album: 'Other Album'), + _track('remote', path: 'https://example.com/music.flac'), + _track('album', path: '/duplicate.flac', genre: 'Rock'), + ]); + expect(results.map((item) => item.trackName), [ + 'album', + 'collaborator', + 'genre', + ]); + }, + ); + + for (final mornye in [false, true]) { + testWidgets('artist picker offers each featured credit ($mornye)', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => navigateToArtistCredits( + context, + artistNames: 'First Artist featured Second Artist', + ), + child: const Text('Artists'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Artists')); + await tester.pumpAndSettle(); + expect(find.text('First Artist'), findsOneWidget); + expect(find.text('Second Artist'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/track_metadata_cover_search_test.dart b/test/track_metadata_cover_search_test.dart index 5181a18f..fa6e48ca 100644 --- a/test/track_metadata_cover_search_test.dart +++ b/test/track_metadata_cover_search_test.dart @@ -165,6 +165,9 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Edit audio file')); + await tester.tap(find.text('Edit audio file')); + await tester.pumpAndSettle(); await tester.ensureVisible(find.text('Edit Metadata')); await tester.tap(find.text('Edit Metadata')); await _settleFileIo(tester); diff --git a/test/track_metadata_replaygain_test.dart b/test/track_metadata_replaygain_test.dart index 83b7a531..d524f6c0 100644 --- a/test/track_metadata_replaygain_test.dart +++ b/test/track_metadata_replaygain_test.dart @@ -142,6 +142,10 @@ void main() { Future openRemoval() async { await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); + expect(find.text('Remove ReplayGain'), findsNothing); + await tester.ensureVisible(find.text('Edit audio file')); + await tester.tap(find.text('Edit audio file')); + await tester.pumpAndSettle(); await tester.ensureVisible(find.text('Remove ReplayGain')); await tester.tap(find.text('Remove ReplayGain')); await tester.pumpAndSettle();