mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
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:
1 parent
4eef5542be
commit
a93e552b3e
22 files changed
+797
-188
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user