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();