From f10a3d4976c96f815f7aca79206a57c8bd06e4e8 Mon Sep 17 00:00:00 2001 From: Rahul Sahani <110347707+Rahul-Sahani04@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:06:10 +0530 Subject: [PATCH] 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 --- .gitignore | 1 + lib/components/track_tile/track_tile.dart | 54 +++++--- lib/modules/player/player_queue.dart | 126 ++++++++++++++++++- lib/modules/player/player_queue_actions.dart | 44 +++++++ lib/pages/settings/blacklist.dart | 2 +- 5 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 lib/modules/player/player_queue_actions.dart diff --git a/.gitignore b/.gitignore index 119e42e5..544dbba8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .history .svn/ + # IntelliJ related *.iml *.ipr diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 955ac90d..ec3f50f3 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -39,6 +39,7 @@ class TrackTile extends HookConsumerWidget { final int? index; final SpotubeTrackObject track; final bool selected; + final bool selectionMode; final ValueChanged? onChanged; final Future 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,7 +230,9 @@ class TrackTile extends HookConsumerWidget { children: [ Expanded( flex: 6, - child: switch (track) { + child: AbsorbPointer( + absorbing: selectionMode, + child: switch (track) { SpotubeLocalTrackObject() => Text( track.name, maxLines: 1, @@ -232,15 +242,17 @@ class TrackTile extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Button( - style: ButtonVariance.link.copyWith( - padding: (context, states, value) => - EdgeInsets.zero, - ), - onPressed: () { - context - .navigateTo(TrackRoute(trackId: track.id)); - }, + child: Button( + style: ButtonVariance.link.copyWith( + padding: (context, states, value) => + EdgeInsets.zero, + ), + onPressed: effectiveSelection + ? null + : () { + context + .navigateTo(TrackRoute(trackId: track.id)); + }, child: Text( track.name, maxLines: 1, @@ -251,6 +263,7 @@ class TrackTile extends HookConsumerWidget { ], ), }, + ), ), if (constrains.mdAndUp) ...[ const SizedBox(width: 8), @@ -281,20 +294,25 @@ class TrackTile extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: track is SpotubeLocalTrackObject + child: track is SpotubeLocalTrackObject ? Text( track.artists.asString(), ) : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink( - artists: track.artists, - onOverflowArtistClick: () { - context.navigateTo( - TrackRoute(trackId: track.id), - ); - }, + child: AbsorbPointer( + absorbing: effectiveSelection, + child: ArtistLink( + artists: track.artists, + onOverflowArtistClick: effectiveSelection + ? () {} + : () { + context.navigateTo( + TrackRoute(trackId: track.id), + ); + }, + ), ), ), ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index c9d5626f..bfb7a2e3 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -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({}); + 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( + 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(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), diff --git a/lib/modules/player/player_queue_actions.dart b/lib/modules/player/player_queue_actions.dart new file mode 100644 index 00000000..3d1666c2 --- /dev/null +++ b/lib/modules/player/player_queue_actions.dart @@ -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), + ); + } +} diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 8ac2c1b9..2af899f3 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -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(