feat(queue): add multi-select and bulk actions to queue (#2839)

* feat(queue): add multi-select and bulk actions to queue

- Add selection mode to PlayerQueue with long-press to select
- Disable inner navigation (title/artist) when selecting via TrackTile
- Show checkboxes only in selection mode
- Add selection AppBar behavior and bottom-sheet menu with: Select all, Add to playlist, Remove selected, Cancel
- Reuse existing PlaylistAddTrackDialog for bulk add
- Hide drag handle while in selection mode

Closes: # (implement multi-select queue feature)

* chore: update .gitignore to include .vscode and modify signing configurations back to default in build.gradle

* chore: add VS Code configuration files

* chore: update dependencies in pubspec.lock

* chore: update pubspec.lock to reflect dependency changes and version updates

* chore: fix replace material widgets with shadcn widgets

---------

Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>
This commit is contained in:
Rahul Sahani 2025-11-10 10:06:10 +05:30 committed by GitHub
parent 3209c75144
commit f10a3d4976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 207 additions and 20 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.history
.svn/
# IntelliJ related
*.iml
*.ipr

View File

@ -39,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
final int? index;
final SpotubeTrackObject track;
final bool selected;
final bool selectionMode;
final ValueChanged<bool?>? onChanged;
final Future<void> Function()? onTap;
final VoidCallback? onLongPress;
@ -53,6 +54,7 @@ class TrackTile extends HookConsumerWidget {
this.index,
required this.track,
this.selected = false,
this.selectionMode = false,
required this.playlist,
this.onTap,
this.onLongPress,
@ -81,6 +83,12 @@ class TrackTile extends HookConsumerWidget {
[track.album.images],
);
// Treat either explicit selectionMode or presence of onChanged as selection
// context. Some lists enable selection by providing `onChanged` without
// toggling a dedicated `selectionMode` flag (e.g. playlists), so we must
// disable inner navigation in both cases.
final effectiveSelection = selectionMode || onChanged != null;
return LayoutBuilder(builder: (context, constrains) {
return Listener(
onPointerDown: (event) {
@ -222,6 +230,8 @@ class TrackTile extends HookConsumerWidget {
children: [
Expanded(
flex: 6,
child: AbsorbPointer(
absorbing: selectionMode,
child: switch (track) {
SpotubeLocalTrackObject() => Text(
track.name,
@ -237,7 +247,9 @@ class TrackTile extends HookConsumerWidget {
padding: (context, states, value) =>
EdgeInsets.zero,
),
onPressed: () {
onPressed: effectiveSelection
? null
: () {
context
.navigateTo(TrackRoute(trackId: track.id));
},
@ -252,6 +264,7 @@ class TrackTile extends HookConsumerWidget {
),
},
),
),
if (constrains.mdAndUp) ...[
const SizedBox(width: 8),
Expanded(
@ -288,9 +301,13 @@ class TrackTile extends HookConsumerWidget {
: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40),
child: AbsorbPointer(
absorbing: effectiveSelection,
child: ArtistLink(
artists: track.artists,
onOverflowArtistClick: () {
onOverflowArtistClick: effectiveSelection
? () {}
: () {
context.navigateTo(
TrackRoute(trackId: track.id),
);
@ -299,6 +316,7 @@ class TrackTile extends HookConsumerWidget {
),
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [

View File

@ -9,13 +9,16 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/fallbacks/not_found.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/player/player_queue_actions.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart';
@ -55,6 +58,9 @@ class PlayerQueue extends HookConsumerWidget {
final controller = useAutoScrollController();
final searchText = useState('');
final selectionMode = useState(false);
final selectedTrackIds = useState(<String>{});
final isSearching = useState(false);
final tracks = playlist.tracks;
@ -131,6 +137,91 @@ class PlayerQueue extends HookConsumerWidget {
surfaceOpacity: 0,
child: searchBar,
)
else if (selectionMode.value)
AppBar(
backgroundColor: Colors.transparent,
surfaceBlur: 0,
surfaceOpacity: 0,
leading: [
IconButton.ghost(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
selectedTrackIds.value = {};
selectionMode.value = false;
},
)
],
title: SizedBox(
height: 30,
child: AutoSizeText(
'${selectedTrackIds.value.length} selected',
maxLines: 1,
),
),
trailing: [
PlayerQueueActionButton(
builder: (context, close) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(12),
ButtonTile(
style: const ButtonStyle.ghost(),
leading:
const Icon(SpotubeIcons.selectionCheck),
title: Text(context.l10n.select_all),
onPressed: () {
selectedTrackIds.value =
filteredTracks.map((t) => t.id).toSet();
Navigator.pop(context);
},
),
ButtonTile(
style: const ButtonStyle.ghost(),
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
onPressed: () async {
final selected = filteredTracks
.where((t) =>
selectedTrackIds.value.contains(t.id))
.toList();
close();
if (selected.isEmpty) return;
final res = await showDialog<bool?>(
context: context,
builder: (context) =>
PlaylistAddTrackDialog(
tracks: selected,
openFromPlaylist: null,
),
);
if (res == true) {
selectedTrackIds.value = {};
selectionMode.value = false;
}
},
),
ButtonTile(
style: const ButtonStyle.ghost(),
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.remove_from_queue),
onPressed: () async {
final ids = selectedTrackIds.value.toList();
close();
if (ids.isEmpty) return;
await Future.wait(
ids.map((id) => onRemove(id)));
if (context.mounted) {
selectedTrackIds.value = {};
selectionMode.value = false;
}
},
),
const Gap(12),
],
),
),
],
)
else
AppBar(
trailingGap: 0,
@ -195,6 +286,20 @@ class PlayerQueue extends HookConsumerWidget {
},
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
void toggleSelection(String id) {
final s = {...selectedTrackIds.value};
if (s.contains(id)) {
s.remove(id);
} else {
s.add(id);
}
selectedTrackIds.value = s;
if (selectedTrackIds.value.isEmpty) {
selectionMode.value = false;
}
}
return AutoScrollTag(
key: ValueKey<int>(i),
controller: controller,
@ -203,15 +308,34 @@ class PlayerQueue extends HookConsumerWidget {
playlist: playlist,
index: i,
track: track,
selectionMode: selectionMode.value,
selected:
selectedTrackIds.value.contains(track.id),
onChanged: selectionMode.value
? (_) => toggleSelection(track.id)
: null,
onTap: () async {
if (selectionMode.value) {
toggleSelection(track.id);
return;
}
if (playlist.activeTrack?.id == track.id) {
return;
}
await onJump(track);
},
onLongPress: () {
if (!selectionMode.value) {
selectionMode.value = true;
selectedTrackIds.value = {track.id};
} else {
toggleSelection(track.id);
}
},
leadingActions: [
if (!isSearching.value &&
searchText.value.isEmpty)
searchText.value.isEmpty &&
!selectionMode.value)
Padding(
padding:
const EdgeInsets.only(left: 8.0),

View File

@ -0,0 +1,44 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
class PlayerQueueActionButton extends StatelessWidget {
final Widget Function(BuildContext context, VoidCallback close) builder;
const PlayerQueueActionButton({
super.key,
required this.builder,
});
@override
Widget build(BuildContext context) {
return IconButton.ghost(
onPressed: () {
final mediaQuery = MediaQuery.sizeOf(context);
if (mediaQuery.lgAndUp) {
showDropdown(
context: context,
builder: (context) {
return SizedBox(
width: 220 * context.theme.scaling,
child: Card(
padding: EdgeInsets.zero,
child: builder(context, () => closeOverlay(context)),
),
);
},
);
} else {
openSheet(
context: context,
builder: (context) => builder(context, () => closeSheet(context)),
position: OverlayPosition.bottom,
);
}
},
icon: const Icon(SpotubeIcons.moreHorizontal),
);
}
}

View File

@ -64,7 +64,7 @@ class BlackListPage extends HookConsumerWidget {
child: TextField(
onChanged: (value) => searchText.value = value,
placeholder: Text(context.l10n.search),
leading: const Icon(SpotubeIcons.search),
// prefixIcon: const Icon(SpotubeIcons.search),
),
),
InterScrollbar(