feat(library): select search results with a long press

Long-pressing a song in Library search results enters multi-select with the Library track actions. Taps toggle songs, select all covers the visible songs, back leaves selection mode, and a new query clears it. Applies to the Mornye Library tab and the Material/Songs search.
This commit is contained in:
zarzet committed 2026-10-01 18:46:21 +07:00
1 parent 86cf860288
commit ad9670eb46
2 files changed
+303 -4

No files matched your search

+195 -4
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/library_search_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -8,11 +10,16 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/library_search.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
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/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
/// Slivers shared by Material's Library and Mornye's Library/Songs screens.
@@ -37,10 +44,165 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
LibrarySearchKind? _kind;
int _pages = 1;
// Long-pressed songs, keyed by UnifiedLibraryItem.id in selection order.
// Only resolved records are kept, so every bar action sees real files.
final Map<String, UnifiedLibraryItem> _selected = {};
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
// Hidden while a sheet or dialog opened from the bar is on screen.
bool _suppressSelectionBar = false;
List<LibrarySearchHit> _visibleSongs = const [];
bool get _selecting => _selected.isNotEmpty;
@override
void didUpdateWidget(covariant LibrarySearchResults oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) _pages = 1;
if (oldWidget.query != widget.query) {
_pages = 1;
// A selection belongs to the results it was made in.
if (_selecting) {
_selected.clear();
_selectionOverlay.hide();
}
}
}
@override
void dispose() {
_selectionOverlay.dispose();
super.dispose();
}
static String _itemId(LibrarySearchHit hit) =>
hit.source == 'local' ? 'local_${hit.id}' : 'dl_${hit.id}';
Future<UnifiedLibraryItem?> _resolve(LibrarySearchHit hit) => ref.read(
librarySearchTrackProvider((source: hit.source, id: hit.id)).future,
);
Future<void> _toggleSelection(LibrarySearchHit hit) async {
final id = _itemId(hit);
if (_selected.containsKey(id)) {
setState(() => _selected.remove(id));
return;
}
final item = await _resolve(hit);
if (!mounted || item == null) return;
if (_selected.isEmpty) {
FocusScope.of(context).unfocus();
HapticFeedback.mediumImpact();
}
setState(() => _selected[item.id] = item);
}
Future<void> _selectAllVisible() async {
final items = await Future.wait(
_visibleSongs
.where((hit) => !_selected.containsKey(_itemId(hit)))
.map(_resolve),
);
if (!mounted) return;
setState(() {
for (final item in items.whereType<UnifiedLibraryItem>()) {
_selected[item.id] = item;
}
});
}
void _exitSelection() {
if (!mounted) return;
setState(_selected.clear);
_selectionOverlay.hide();
}
void _finishSelectionAction() {
_exitSelection();
ref.invalidate(librarySearchProvider);
}
void _hideSelectionBar() {
_suppressSelectionBar = true;
_selectionOverlay.hide();
}
Future<void> _restoreSelectionBar({Duration delay = Duration.zero}) async {
// Wait out the sheet/dialog exit so the bar does not cover it.
if (delay > Duration.zero) await Future<void>.delayed(delay);
_suppressSelectionBar = false;
if (mounted) setState(() {});
}
void _syncSelectionBar() {
if (!mounted) return;
if (!_selecting || _suppressSelectionBar) {
_selectionOverlay.hide();
return;
}
_selectionOverlay.show(context, _buildSelectionBar);
}
Widget _buildSelectionBar(BuildContext _) {
final items = _selected.values.toList(growable: false);
final visibleIds = _visibleSongs.map(_itemId).toSet();
final allSelected =
visibleIds.isNotEmpty && visibleIds.every(_selected.containsKey);
final flacEligible = items.every((item) => item.localItem != null)
? items
.map((item) => item.localItem!)
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false)
: const <LocalLibraryItem>[];
return LibraryTrackSelectionBar(
selectedCount: items.length,
allSelected: allSelected,
onClose: _exitSelection,
onToggleSelectAll: allSelected ? _exitSelection : _selectAllVisible,
bottomPadding: MediaQuery.paddingOf(context).bottom,
flacEligibleCount: flacEligible.length,
onQueueFlac: () => queueLocalTracksAsFlac(
context,
ref,
flacEligible,
isActive: () => mounted,
onComplete: _exitSelection,
),
onReEnrich: () => reEnrichLibraryTracks(
context,
ref,
items,
isActive: () => mounted,
onSelectionHide: () async => _hideSelectionBar(),
onSelectionRestore: _restoreSelectionBar,
onComplete: _finishSelectionAction,
),
onConvert: () => showBatchConvertSheet(
context,
ref,
items,
onExitSelectionMode: _finishSelectionAction,
onSheetOpen: _hideSelectionBar,
onSheetClosed: (_) =>
_restoreSelectionBar(delay: const Duration(milliseconds: 260)),
),
onReplayGain: ({required remove}) => runBatchReplayGain(
context,
items,
remove: remove,
onExitSelectionMode: _exitSelection,
onConfirmOpen: _hideSelectionBar,
onConfirmClosed: (confirmed) => _restoreSelectionBar(
delay: confirmed ? Duration.zero : const Duration(milliseconds: 220),
),
),
onDelete: () => deleteLibraryTracks(
context,
ref,
items,
isActive: () => mounted,
onComplete: _finishSelectionAction,
),
);
}
String _label(LibrarySearchKind kind) => switch (kind) {
@@ -148,6 +310,7 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(child: Icon(icon)),
);
final song = hit.kind == LibrarySearchKind.songs;
return TrackCard(
key: ValueKey('${hit.kind.name}:${hit.source}:${hit.id}'),
style: hit.kind == LibrarySearchKind.songs && !context.isMornye
@@ -166,14 +329,23 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
),
title: hit.title,
subtitle: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
trailing: hit.kind == LibrarySearchKind.songs
trailing: song
? IconButton(
tooltip: context.l10n.tooltipPlay,
icon: const Icon(Icons.play_arrow_rounded),
onPressed: () => _open(hit, play: true),
)
: const Icon(Icons.chevron_right),
onTap: () => _open(hit),
// Long-press selects songs for the Library batch actions; while
// selecting, song taps toggle and other results stay inert.
isSelectionMode: song && _selecting,
isSelected: song && _selected.containsKey(_itemId(hit)),
onTap: !_selecting
? () => _open(hit)
: song
? () => _toggleSelection(hit)
: null,
onLongPress: song ? () => _toggleSelection(hit) : null,
);
}
@@ -204,6 +376,7 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
var hasResults = false;
var loading = false;
var failed = false;
final visibleSongs = <LibrarySearchHit>[];
for (final kind in _kind == null ? LibrarySearchKind.values : [_kind!]) {
for (var page = 0; page < (_kind == null ? 1 : _pages); page++) {
final pageSize = _kind == null ? 5 : 40;
@@ -217,6 +390,9 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
loading |= result.isLoading;
failed |= result.hasError;
final hits = result.value ?? const <LibrarySearchHit>[];
if (kind == LibrarySearchKind.songs) {
visibleSongs.addAll(hits.take(pageSize));
}
if (hits.isNotEmpty) {
hasResults = true;
if (page == 0) {
@@ -293,6 +469,21 @@ class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
),
);
}
return SliverMainAxisGroup(slivers: slivers);
if (_selecting) {
// Same reserve as the Library list, so the last rows can scroll clear
// of the selection bar.
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 100)));
}
_visibleSongs = visibleSongs;
if (_selecting || _selectionOverlay.isVisible) {
WidgetsBinding.instance.addPostFrameCallback((_) => _syncSelectionBar());
}
return PopScope(
canPop: !_selecting,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _selecting) _exitSelection();
},
child: SliverMainAxisGroup(slivers: slivers),
);
}
}
+108
View File
@@ -259,4 +259,112 @@ void main() {
expect(find.text('old songs 0'), findsNothing);
expect(tester.takeException(), isNull);
});
for (final mornye in [false, true]) {
testWidgets(
'long-press selects songs for Library batch actions ($mornye)',
(tester) async {
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
// Tall enough that the selection bar never covers a result row.
tester.view.physicalSize = const Size(800, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
UnifiedLibraryItem local(String id) =>
UnifiedLibraryItem.fromLocalLibrary(
LocalLibraryItem(
id: id,
trackName: 'Song $id',
artistName: 'Artist',
albumName: 'Album',
filePath: '/music/$id.mp3',
scannedAt: DateTime(2026),
),
);
final query = ValueNotifier('Song');
addTearDown(query.dispose);
final player = _PlaybackRecorder();
await tester.pumpWidget(
ProviderScope(
overrides: [
playbackProvider.overrideWith(() => player),
librarySearchProvider.overrideWith(
(ref, request) async => switch (request.kind) {
LibrarySearchKind.songs => [
for (final id in ['a', 'b'])
LibrarySearchHit(
kind: LibrarySearchKind.songs,
id: id,
title: 'Song $id',
source: 'local',
),
],
LibrarySearchKind.albums => [
_hit(LibrarySearchKind.albums, 0, query: 'Song'),
],
_ => [],
},
),
librarySearchTrackProvider.overrideWith(
(ref, key) async => local(key.id),
),
],
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ValueListenableBuilder(
valueListenable: query,
builder: (context, value, _) => CustomScrollView(
slivers: [
LibrarySearchResults(query: value, onOpenArtist: (_) {}),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Play'), findsNWidgets(2));
await tester.longPress(find.text('Song a'));
await tester.pumpAndSettle();
expect(find.text('1 selected'), findsOneWidget);
expect(find.text('Re-enrich (1)'), findsOneWidget);
expect(find.text('Convert 1 track'), findsOneWidget);
expect(find.text('Delete 1 track'), findsOneWidget);
// Selecting hides row actions and keeps taps on the selection.
expect(find.byTooltip('Play'), findsNothing);
await tester.tap(find.text('Song b'));
await tester.pumpAndSettle();
expect(find.text('2 selected'), findsOneWidget);
expect(find.text('All tracks selected'), findsOneWidget);
await tester.tap(find.text('Song albums 0'));
await tester.pumpAndSettle();
expect(find.byType(LibrarySearchResults), findsOneWidget);
expect(find.text('2 selected'), findsOneWidget);
expect(player.paths, isEmpty);
await tester.tap(find.text('Song b'));
await tester.pumpAndSettle();
expect(find.text('1 selected'), findsOneWidget);
// Back leaves selection mode instead of the screen.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.textContaining('selected'), findsNothing);
expect(find.byTooltip('Play'), findsNWidgets(2));
// A new query starts without the previous selection.
await tester.longPress(find.text('Song a'));
await tester.pumpAndSettle();
expect(find.text('1 selected'), findsOneWidget);
query.value = 'Song ';
await tester.pumpAndSettle();
expect(find.textContaining('selected'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
}