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 .history
.svn/ .svn/
# IntelliJ related # IntelliJ related
*.iml *.iml
*.ipr *.ipr

View File

@ -39,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
final int? index; final int? index;
final SpotubeTrackObject track; final SpotubeTrackObject track;
final bool selected; final bool selected;
final bool selectionMode;
final ValueChanged<bool?>? onChanged; final ValueChanged<bool?>? onChanged;
final Future<void> Function()? onTap; final Future<void> Function()? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
@ -53,6 +54,7 @@ class TrackTile extends HookConsumerWidget {
this.index, this.index,
required this.track, required this.track,
this.selected = false, this.selected = false,
this.selectionMode = false,
required this.playlist, required this.playlist,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
@ -81,6 +83,12 @@ class TrackTile extends HookConsumerWidget {
[track.album.images], [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 LayoutBuilder(builder: (context, constrains) {
return Listener( return Listener(
onPointerDown: (event) { onPointerDown: (event) {
@ -222,6 +230,8 @@ class TrackTile extends HookConsumerWidget {
children: [ children: [
Expanded( Expanded(
flex: 6, flex: 6,
child: AbsorbPointer(
absorbing: selectionMode,
child: switch (track) { child: switch (track) {
SpotubeLocalTrackObject() => Text( SpotubeLocalTrackObject() => Text(
track.name, track.name,
@ -237,7 +247,9 @@ class TrackTile extends HookConsumerWidget {
padding: (context, states, value) => padding: (context, states, value) =>
EdgeInsets.zero, EdgeInsets.zero,
), ),
onPressed: () { onPressed: effectiveSelection
? null
: () {
context context
.navigateTo(TrackRoute(trackId: track.id)); .navigateTo(TrackRoute(trackId: track.id));
}, },
@ -252,6 +264,7 @@ class TrackTile extends HookConsumerWidget {
), ),
}, },
), ),
),
if (constrains.mdAndUp) ...[ if (constrains.mdAndUp) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@ -288,9 +301,13 @@ class TrackTile extends HookConsumerWidget {
: ClipRect( : ClipRect(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40), constraints: const BoxConstraints(maxHeight: 40),
child: AbsorbPointer(
absorbing: effectiveSelection,
child: ArtistLink( child: ArtistLink(
artists: track.artists, artists: track.artists,
onOverflowArtistClick: () { onOverflowArtistClick: effectiveSelection
? () {}
: () {
context.navigateTo( context.navigateTo(
TrackRoute(trackId: track.id), TrackRoute(trackId: track.id),
); );
@ -299,6 +316,7 @@ class TrackTile extends HookConsumerWidget {
), ),
), ),
), ),
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.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/fallbacks/not_found.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/track_tile/track_tile.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
import 'package:spotube/models/metadata/metadata.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/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/audio_player/state.dart';
@ -55,6 +58,9 @@ class PlayerQueue extends HookConsumerWidget {
final controller = useAutoScrollController(); final controller = useAutoScrollController();
final searchText = useState(''); final searchText = useState('');
final selectionMode = useState(false);
final selectedTrackIds = useState(<String>{});
final isSearching = useState(false); final isSearching = useState(false);
final tracks = playlist.tracks; final tracks = playlist.tracks;
@ -131,6 +137,91 @@ class PlayerQueue extends HookConsumerWidget {
surfaceOpacity: 0, surfaceOpacity: 0,
child: searchBar, 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 else
AppBar( AppBar(
trailingGap: 0, trailingGap: 0,
@ -195,6 +286,20 @@ class PlayerQueue extends HookConsumerWidget {
}, },
itemBuilder: (context, i) { itemBuilder: (context, i) {
final track = filteredTracks.elementAt(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( return AutoScrollTag(
key: ValueKey<int>(i), key: ValueKey<int>(i),
controller: controller, controller: controller,
@ -203,15 +308,34 @@ class PlayerQueue extends HookConsumerWidget {
playlist: playlist, playlist: playlist,
index: i, index: i,
track: track, track: track,
selectionMode: selectionMode.value,
selected:
selectedTrackIds.value.contains(track.id),
onChanged: selectionMode.value
? (_) => toggleSelection(track.id)
: null,
onTap: () async { onTap: () async {
if (selectionMode.value) {
toggleSelection(track.id);
return;
}
if (playlist.activeTrack?.id == track.id) { if (playlist.activeTrack?.id == track.id) {
return; return;
} }
await onJump(track); await onJump(track);
}, },
onLongPress: () {
if (!selectionMode.value) {
selectionMode.value = true;
selectedTrackIds.value = {track.id};
} else {
toggleSelection(track.id);
}
},
leadingActions: [ leadingActions: [
if (!isSearching.value && if (!isSearching.value &&
searchText.value.isEmpty) searchText.value.isEmpty &&
!selectionMode.value)
Padding( Padding(
padding: padding:
const EdgeInsets.only(left: 8.0), 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( child: TextField(
onChanged: (value) => searchText.value = value, onChanged: (value) => searchText.value = value,
placeholder: Text(context.l10n.search), placeholder: Text(context.l10n.search),
leading: const Icon(SpotubeIcons.search), // prefixIcon: const Icon(SpotubeIcons.search),
), ),
), ),
InterScrollbar( InterScrollbar(