feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s

Add a global selection store backing a checkbox per track row and a
floating SelectionBar above PlayerBar with Play next / Add to queue /
Add to playlist / Like all / Clear.

- selection/store.svelte.ts: id Set + TrackRef Map + anchor index;
  toggleOne, selectRange (shift+click), clearSelection. Singleton —
  layout effect clears on pathname change.
- TrackRow: checkbox slot replaces the track number on hover; row
  click toggles selection once any row is picked; shift+click extends
  the range. Esc clears (takes priority over closing the queue
  drawer).
- SelectionBar: floating pill above PlayerBar, mounted inside the
  QueryClientProvider so the Like-all action can resolve.
- player.playNextMany: bulk variant of playNext for "Play next" on a
  multi-track selection.

Covered by selection-store unit tests and three new TrackRow tests
(checkbox toggle, sticky select-mode row click, shift+click range).
This commit is contained in:
2026-06-01 11:41:29 -04:00
parent bbc1036f77
commit fd12a145c8
7 changed files with 409 additions and 15 deletions
+17 -2
View File
@@ -8,7 +8,9 @@
import { isPublicRoute } from '$lib/auth/publicRoutes';
import Shell from '$lib/components/Shell.svelte';
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
import SelectionBar from '$lib/components/SelectionBar.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import {
registerAudioEl,
reportTimeUpdate,
@@ -104,14 +106,24 @@
$effect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && player.queueDrawerOpen) {
closeQueueDrawer();
if (e.key === 'Escape') {
// Selection bar takes priority over queue drawer: pressing Esc
// with both open clears the (less-modal) selection first.
if (selection.active) clearSelection();
else if (player.queueDrawerOpen) closeQueueDrawer();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
// Route-change reset: selection state is per-page; navigating away
// (or to a different track-list surface) clears whatever was picked.
$effect(() => {
page.url.pathname; // subscribe
clearSelection();
});
useMediaSession();
useEventsDispatcher();
useGlobalShortcuts();
@@ -156,6 +168,9 @@
{:else}
{@render children()}
{/if}
<!-- SelectionBar must be inside the QueryClientProvider so its
Like-all action can use useQueryClient. -->
<SelectionBar />
</QueryClientProvider>
<ToastHost />