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.
This commit is contained in:
zarzet committed 2026-10-02 18:11:37 +07:00
1 parent 4eef5542be
commit a93e552b3e
22 files changed
+797 -188

No files matched your search

+5
View File
@@ -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"
+6 -1
View File
@@ -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."
}
+14
View File
@@ -199,6 +199,20 @@ class MusicPlayerController {
Future<void> addToQueue(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item);
Future<void> enqueueLibraryItems(
List<UnifiedLibraryItem> 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<void> playNextHistory(DownloadHistoryItem item) async =>
playNext(playableFromHistory(item));
+16
View File
@@ -798,6 +798,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
},
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(
+63 -19
View File
@@ -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<ArtistScreen>
int _popularCurrentPage = 0;
bool _isFetchingDiscography = false;
int _discographyFetchGeneration = 0;
List<ArtistAlbum>? _albumBucketSource;
List<ArtistAlbum> _albumsOnlyBucket = const [];
List<ArtistAlbum> _singlesBucket = const [];
@@ -849,6 +853,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
? 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<ArtistScreen>
Future<void> _fetchAndQueueAlbums(
List<ArtistAlbum> 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<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
key: progressDialogKey,
totalAlbums: albums.length,
onCancel: () {
unawaited(
showAppDialog<void>(
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 = <Track>[];
@@ -1075,14 +1105,20 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
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<ArtistScreen>
}
}
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<ArtistScreen>
return;
}
if (addToPlaylist) {
await showAddTracksToPlaylistSheet(context, ref, allTracks);
return;
}
final settings = ref.read(settingsProvider);
final skipExisting =
settings.deduplicateDownloads && !settings.allowQualityVariants;
+9
View File
@@ -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<DownloadedAlbumScreen>
},
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;
+9
View File
@@ -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<LocalAlbumScreen>
},
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;
+1
View File
@@ -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';
+14
View File
@@ -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) {
+4
View File
@@ -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,
@@ -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<List<UnifiedLibraryItem>, 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<void>(
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(),
],
),
);
}
}
+2 -2
View File
@@ -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';
+144 -161
View File
@@ -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<void>(
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 = <List<MornyeMenuAction>>[];
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<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,
);
}
}
+108
View File
@@ -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<UnifiedLibraryItem> rankRelatedLibraryTracks(
UnifiedLibraryItem seed,
List<UnifiedLibraryItem> 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<List<UnifiedLibraryItem>> loadRelatedLibraryTracks(
UnifiedLibraryItem seed, {
required bool includeLocal,
}) async {
final database = LibraryDatabase.instance;
final count = (await database.getQueueCounts(
QueueLibraryDbQuery(includeLocal: includeLocal),
)).allTrackCount;
final rows = <Map<String, dynamic>>[];
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 = <UnifiedLibraryItem>[];
for (final row in rows) {
final json = Map<String, dynamic>.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);
}
+5
View File
@@ -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<LibrarySearchResults> {
.toList(growable: false)
: const <LocalLibraryItem>[];
return LibraryTrackSelectionBar(
playbackActions: LibrarySelectionPlaybackActions(
items: items,
onClose: _exitSelection,
),
selectedCount: items.length,
allSelected: allSelected,
onClose: _exitSelection,
@@ -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<UnifiedLibraryItem> items;
final VoidCallback onClose;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme;
final actions = <Widget>[
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),
],
),
),
);
}
}
+10 -3
View File
@@ -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;
+8 -2
View File
@@ -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,
);
}
}
@@ -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<String> enqueued = [];
bool insertedNext = false;
@override
Future<void> enqueueLibraryItems(
List<UnifiedLibraryItem> 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);
});
}
}
}
+102
View File
@@ -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());
});
}
}
@@ -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);
+4
View File
@@ -142,6 +142,10 @@ void main() {
Future<void> 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();