Files
SpotiFLAC-Mobile/lib/widgets/library_search_results.dart
T
zarzet ad9670eb46 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.
2026-10-01 18:46:21 +07:00

490 lines
17 KiB
Dart

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';
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.
/// Only the current query's provider families are watched; late results from
/// an earlier query cannot replace what the user is currently searching for.
class LibrarySearchResults extends ConsumerStatefulWidget {
const LibrarySearchResults({
super.key,
required this.query,
required this.onOpenArtist,
});
final String query;
final ValueChanged<String> onOpenArtist;
@override
ConsumerState<LibrarySearchResults> createState() =>
_LibrarySearchResultsState();
}
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;
// 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) {
LibrarySearchKind.songs => context.l10n.searchSongs,
LibrarySearchKind.albums => context.l10n.searchAlbums,
LibrarySearchKind.artists => context.l10n.searchArtists,
LibrarySearchKind.playlists => context.l10n.searchPlaylists,
};
void _select(LibrarySearchKind? kind) => setState(() {
_kind = kind;
_pages = 1;
});
Future<void> _open(LibrarySearchHit hit, {bool play = false}) async {
FocusScope.of(context).unfocus();
try {
switch (hit.kind) {
case LibrarySearchKind.songs:
final item = await ref.read(
librarySearchTrackProvider((source: hit.source, id: hit.id)).future,
);
if (!mounted || item == null) return;
if (!play) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TrackMetadataScreen(
item: item.historyItem,
localItem: item.localItem,
),
),
);
return;
}
final media = item.localItem != null
? playableFromLocal(item.localItem!)
: playableFromHistory(item.historyItem!);
await ref
.read(playbackProvider.notifier)
.playMediaQueue(
[media],
startIndex: 0,
externalPath: media.source,
);
case LibrarySearchKind.artists:
widget.onOpenArtist(hit.title);
case LibrarySearchKind.playlists:
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LibraryTracksFolderScreen(
mode: LibraryTracksFolderMode.playlist,
playlistId: hit.id,
),
),
);
case LibrarySearchKind.albums:
if (hit.source == 'local') {
final rows = await LibraryDatabase.instance
.getQueueLocalAlbumTracksByKey(hit.id);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LocalAlbumScreen(
albumName: hit.title,
artistName: hit.artist,
coverPath: hit.cover,
tracks: rows.map(LocalLibraryItem.fromJson).toList(),
),
),
);
} else {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DownloadedAlbumScreen(
albumName: hit.title,
artistName: hit.artist,
coverUrl: hit.cover,
),
),
);
}
}
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$error'))),
);
}
}
Widget _row(LibrarySearchHit hit) {
final icon = switch (hit.kind) {
LibrarySearchKind.songs => Icons.music_note_outlined,
LibrarySearchKind.albums => Icons.album_outlined,
LibrarySearchKind.artists => Icons.person_outline,
LibrarySearchKind.playlists => Icons.queue_music,
};
final subtitle = hit.kind == LibrarySearchKind.songs
? hit.artist
: [
if (hit.kind == LibrarySearchKind.albums) hit.artist,
context.l10n.queueTrackCount(hit.trackCount),
].join(' · ');
Widget fallback(BuildContext context) => ColoredBox(
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
? TrackCardStyle.filled
: TrackCardStyle.flat,
leading: ClipRRect(
borderRadius: BorderRadius.circular(
hit.kind == LibrarySearchKind.artists ? 28 : 8,
),
child: SizedBox.square(
dimension: 52,
child: hit.cover?.isNotEmpty == true
? LocalOrNetworkCoverImage(url: hit.cover!, placeholder: fallback)
: fallback(context),
),
),
title: hit.title,
subtitle: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
trailing: song
? IconButton(
tooltip: context.l10n.tooltipPlay,
icon: const Icon(Icons.play_arrow_rounded),
onPressed: () => _open(hit, play: true),
)
: const Icon(Icons.chevron_right),
// 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,
);
}
@override
Widget build(BuildContext context) {
final slivers = <Widget>[
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
spacing: 8,
children: [
for (final kind in [null, ...LibrarySearchKind.values])
AppChoiceChip(
label: Text(
kind == null ? context.l10n.historyFilterAll : _label(kind),
),
selected: _kind == kind,
singleChoice: true,
onSelected: (_) => _select(kind),
),
],
),
),
),
];
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;
final request = (
query: widget.query,
kind: kind,
limit: pageSize + 1,
offset: page * pageSize,
);
final result = ref.watch(librarySearchProvider(request));
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) {
slivers.add(
SliverToBoxAdapter(
child: ListTile(
title: Text(
_label(kind),
style: Theme.of(context).textTheme.titleLarge,
),
trailing: _kind == null
? const Icon(Icons.chevron_right)
: null,
onTap: _kind == null ? () => _select(kind) : null,
),
),
);
}
slivers.add(
SliverList.builder(
itemCount: hits.length.clamp(0, pageSize),
itemBuilder: (context, index) => _row(hits[index]),
),
);
if (_kind != null && page == _pages - 1 && hits.length > pageSize) {
slivers.add(
SliverToBoxAdapter(
child: Center(
child: IconButton(
tooltip: _label(kind),
icon: const Icon(Icons.expand_more),
onPressed: () => setState(() => _pages++),
),
),
),
);
}
}
if (result.hasError) {
slivers.add(
SliverToBoxAdapter(
child: Center(
child: TextButton.icon(
icon: const Icon(Icons.refresh),
label: Text('${_label(kind)} · ${context.l10n.dialogRetry}'),
onPressed: () =>
ref.invalidate(librarySearchProvider(request)),
),
),
),
);
}
}
}
if (loading) {
slivers.add(
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator.adaptive()),
),
),
);
} else if (!hasResults && !failed) {
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
context.l10n.searchEmptyResultSubtitle,
textAlign: TextAlign.center,
),
),
),
);
}
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),
);
}
}