From ad9670eb461614bd63da1ce13ad5ee991fda864b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:46:21 +0700 Subject: [PATCH] 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. --- lib/widgets/library_search_results.dart | 199 +++++++++++++++++++++++- test/library_search_results_test.dart | 108 +++++++++++++ 2 files changed, 303 insertions(+), 4 deletions(-) diff --git a/lib/widgets/library_search_results.dart b/lib/widgets/library_search_results.dart index 4e4e5c6e..7aa82081 100644 --- a/lib/widgets/library_search_results.dart +++ b/lib/widgets/library_search_results.dart @@ -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 { 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 _selected = {}; + final SelectionOverlayController _selectionOverlay = + SelectionOverlayController(); + // Hidden while a sheet or dialog opened from the bar is on screen. + bool _suppressSelectionBar = false; + List _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 _resolve(LibrarySearchHit hit) => ref.read( + librarySearchTrackProvider((source: hit.source, id: hit.id)).future, + ); + + Future _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 _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()) { + _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 _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.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 []; + 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 { 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 { ), 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 { var hasResults = false; var loading = false; var failed = false; + final visibleSongs = []; 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 { loading |= result.isLoading; failed |= result.hasError; final hits = result.value ?? const []; + 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 { ), ); } - 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), + ); } } diff --git a/test/library_search_results_test.dart b/test/library_search_results_test.dart index e96bcc35..5e6122a5 100644 --- a/test/library_search_results_test.dart +++ b/test/library_search_results_test.dart @@ -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); + }, + ); + } }