diff --git a/web/src/lib/components/SelectionBar.svelte b/web/src/lib/components/SelectionBar.svelte
new file mode 100644
index 00000000..3b3cba4f
--- /dev/null
+++ b/web/src/lib/components/SelectionBar.svelte
@@ -0,0 +1,129 @@
+
+
+{#if selection.active}
+
+
+
+
+ {selection.count} selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if addOpen}
+
+ {/if}
+
+{/if}
diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte
index 7417e7d9..d46a2e8c 100644
--- a/web/src/lib/components/TrackRow.svelte
+++ b/web/src/lib/components/TrackRow.svelte
@@ -2,9 +2,12 @@
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
- import { Plus } from 'lucide-svelte';
+ import { Plus, Check } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
+ import {
+ selection, toggleOne, selectRange
+ } from '$lib/selection/store.svelte';
type PlayHandler = (tracks: TrackRef[], index: number) => void;
@@ -16,29 +19,45 @@
const track = $derived(tracks[index]);
- // Mirrors QueueTrackRow's "now playing" treatment so the user sees
- // which row in the album / liked / history / search view is currently
- // playing without having to read the player bar. Only the accent
- // border + bg lift — no "Now playing" pill, since the player bar
- // already names the track.
- const isCurrent = $derived(player.current?.id === track.id);
+ const isCurrent = $derived(player.current?.id === track.id);
+ const isSelected = $derived(selection.isSelected(track.id));
+ const selectMode = $derived(selection.active);
function activate() {
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
}
- function onRowClick() {
+ function onRowClick(e: MouseEvent) {
+ // In selection mode any click toggles; outside selection mode click
+ // plays as before. Shift+click extends the range from the anchor
+ // regardless of mode (enters selection mode if nothing was picked).
+ if (e.shiftKey) {
+ e.preventDefault();
+ selectRange(index, tracks);
+ return;
+ }
+ if (selectMode) {
+ toggleOne(track, index);
+ return;
+ }
activate();
}
function onRowKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- activate();
+ if (selectMode) toggleOne(track, index);
+ else activate();
}
}
+ function onCheckboxClick(e: MouseEvent) {
+ e.stopPropagation();
+ if (e.shiftKey) selectRange(index, tracks);
+ else toggleOne(track, index);
+ }
+
function onAddClick(e: MouseEvent) {
e.stopPropagation();
enqueueTrack(track);
@@ -54,12 +73,34 @@
role="button"
tabindex="0"
aria-label={track.title}
+ data-selected={isSelected}
onclick={onRowClick}
onkeydown={onRowKey}
- class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
+ class="group grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''} {isSelected ? 'bg-surface-hover' : ''}"
>
-
- {track.track_number ?? '—'}
+
+
+ {track.track_number ?? '—'}
+
+
{track.title}
diff --git a/web/src/lib/components/TrackRow.test.ts b/web/src/lib/components/TrackRow.test.ts
index b95cb542..fa665e90 100644
--- a/web/src/lib/components/TrackRow.test.ts
+++ b/web/src/lib/components/TrackRow.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
@@ -18,12 +18,14 @@ vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
+import { selection, clearSelection } from '$lib/selection/store.svelte';
const tracks: TrackRef[] = [
makeTrack({ title: 'So What', duration_sec: 545 }),
makeTrack({ id: 't2', title: 'Freddie Freeloader', track_number: 2, duration_sec: 565, stream_url: '/api/tracks/t2/stream' })
];
+beforeEach(() => clearSelection());
afterEach(() => vi.clearAllMocks());
describe('TrackRow', () => {
@@ -86,4 +88,40 @@ describe('TrackRow', () => {
screen.getByRole('button', { name: /track actions for/i })
).toBeInTheDocument();
});
+
+ describe('selection mode', () => {
+ test('clicking the checkbox toggles selection without playing', async () => {
+ render(TrackRow, { props: { tracks, index: 0 } });
+ const box = screen.getByRole('checkbox', { name: /select so what/i });
+ await fireEvent.click(box);
+ expect(selection.isSelected('t1')).toBe(true);
+ expect(playQueue).not.toHaveBeenCalled();
+ });
+
+ test('row click toggles selection (not play) once any row is selected', async () => {
+ render(TrackRow, { props: { tracks, index: 0 } });
+ const box = screen.getByRole('checkbox', { name: /select so what/i });
+ await fireEvent.click(box);
+
+ // Re-render at index 1 — selection mode is now active globally.
+ const { container } = render(TrackRow, { props: { tracks, index: 1 } });
+ const row = container.querySelector('[role="button"][aria-label="Freddie Freeloader"]')!;
+ await fireEvent.click(row);
+ expect(selection.isSelected('t2')).toBe(true);
+ expect(playQueue).not.toHaveBeenCalled();
+ });
+
+ test('shift+click on the row extends the range from the anchor', async () => {
+ render(TrackRow, { props: { tracks, index: 0 } });
+ const box = screen.getByRole('checkbox', { name: /select so what/i });
+ await fireEvent.click(box);
+
+ const { container } = render(TrackRow, { props: { tracks, index: 1 } });
+ const row = container.querySelector('[role="button"][aria-label="Freddie Freeloader"]')!;
+ await fireEvent.click(row, { shiftKey: true });
+ expect(selection.isSelected('t1')).toBe(true);
+ expect(selection.isSelected('t2')).toBe(true);
+ expect(selection.count).toBe(2);
+ });
+ });
});
diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts
index 2a9978d2..f720cfc3 100644
--- a/web/src/lib/player/store.svelte.ts
+++ b/web/src/lib/player/store.svelte.ts
@@ -288,6 +288,21 @@ export function enqueueTracks(ts: TrackRef[]): void {
if (_state === 'idle') _index = 0;
}
+// Bulk variant of playNext: inserts every track immediately after the
+// currently-playing one, preserving order. Empty queue seeds the queue
+// with the batch at index 0 (mirrors playNext's single-track shape).
+export function playNextMany(ts: TrackRef[]): void {
+ _radioSeedId = null;
+ if (ts.length === 0) return;
+ if (_queue.length === 0) {
+ _queue = [...ts];
+ _index = 0;
+ return;
+ }
+ const next = _index + 1;
+ _queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
+}
+
export async function playRadio(seedTrackId: string): Promise {
const resp = await api.get(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
diff --git a/web/src/lib/selection/store.svelte.test.ts b/web/src/lib/selection/store.svelte.test.ts
new file mode 100644
index 00000000..3bde61c4
--- /dev/null
+++ b/web/src/lib/selection/store.svelte.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, test, beforeEach } from 'vitest';
+import type { TrackRef } from '$lib/api/types';
+import { makeTrack } from '$test-utils/fixtures/track';
+import {
+ selection, toggleOne, selectRange, clearSelection
+} from './store.svelte';
+
+function tracks(): TrackRef[] {
+ return [
+ makeTrack({ id: 'a', title: 'A' }),
+ makeTrack({ id: 'b', title: 'B' }),
+ makeTrack({ id: 'c', title: 'C' }),
+ makeTrack({ id: 'd', title: 'D' })
+ ];
+}
+
+beforeEach(() => clearSelection());
+
+describe('selection store', () => {
+ test('starts empty + inactive', () => {
+ expect(selection.count).toBe(0);
+ expect(selection.active).toBe(false);
+ expect(selection.tracks).toEqual([]);
+ });
+
+ test('toggleOne selects and deselects', () => {
+ const ts = tracks();
+ toggleOne(ts[1], 1);
+ expect(selection.isSelected('b')).toBe(true);
+ expect(selection.count).toBe(1);
+ expect(selection.active).toBe(true);
+
+ toggleOne(ts[1], 1);
+ expect(selection.isSelected('b')).toBe(false);
+ expect(selection.count).toBe(0);
+ expect(selection.active).toBe(false);
+ });
+
+ test('selectRange extends from the last anchor inclusive', () => {
+ const ts = tracks();
+ toggleOne(ts[0], 0);
+ selectRange(2, ts);
+ expect(selection.isSelected('a')).toBe(true);
+ expect(selection.isSelected('b')).toBe(true);
+ expect(selection.isSelected('c')).toBe(true);
+ expect(selection.isSelected('d')).toBe(false);
+ expect(selection.count).toBe(3);
+ });
+
+ test('selectRange handles reverse direction', () => {
+ const ts = tracks();
+ toggleOne(ts[3], 3);
+ selectRange(1, ts);
+ expect(selection.isSelected('b')).toBe(true);
+ expect(selection.isSelected('c')).toBe(true);
+ expect(selection.isSelected('d')).toBe(true);
+ expect(selection.isSelected('a')).toBe(false);
+ });
+
+ test('selectRange with no anchor collapses to a single toggle', () => {
+ const ts = tracks();
+ selectRange(2, ts);
+ expect(selection.isSelected('c')).toBe(true);
+ expect(selection.count).toBe(1);
+ });
+
+ test('selection.tracks returns the full TrackRef payloads', () => {
+ const ts = tracks();
+ toggleOne(ts[0], 0);
+ toggleOne(ts[2], 2);
+ const picked = selection.tracks;
+ const titles = picked.map((t) => t.title).sort();
+ expect(titles).toEqual(['A', 'C']);
+ });
+
+ test('clearSelection resets state', () => {
+ const ts = tracks();
+ toggleOne(ts[0], 0);
+ toggleOne(ts[1], 1);
+ clearSelection();
+ expect(selection.count).toBe(0);
+ expect(selection.active).toBe(false);
+
+ // Anchor is reset too — a follow-up range collapses to one.
+ selectRange(2, ts);
+ expect(selection.count).toBe(1);
+ });
+});
diff --git a/web/src/lib/selection/store.svelte.ts b/web/src/lib/selection/store.svelte.ts
new file mode 100644
index 00000000..6a845623
--- /dev/null
+++ b/web/src/lib/selection/store.svelte.ts
@@ -0,0 +1,68 @@
+import type { TrackRef } from '$lib/api/types';
+
+// Global multi-select state for track lists (album, library/liked,
+// search results, etc.). Reset on route change by the layout effect.
+//
+// The store keeps both a Set of ids (for O(1) isSelected) and a Map of
+// id->TrackRef (so the bulk-action bar can act on the full TrackRef
+// payload without re-querying the source list). Shift-range uses the
+// most recent toggle as the anchor.
+
+let _selectedIds = $state(new Set());
+let _selectedTrackMap = $state(new Map());
+let _anchorIndex = $state(null);
+
+export const selection = {
+ get count(): number { return _selectedIds.size; },
+ get active(): boolean { return _selectedIds.size > 0; },
+ get tracks(): TrackRef[] {
+ return Array.from(_selectedTrackMap.values());
+ },
+ get ids(): string[] { return Array.from(_selectedIds); },
+ isSelected(id: string): boolean { return _selectedIds.has(id); }
+};
+
+export function toggleOne(track: TrackRef, index: number): void {
+ const ids = new Set(_selectedIds);
+ const map = new Map(_selectedTrackMap);
+ if (ids.has(track.id)) {
+ ids.delete(track.id);
+ map.delete(track.id);
+ } else {
+ ids.add(track.id);
+ map.set(track.id, track);
+ }
+ _selectedIds = ids;
+ _selectedTrackMap = map;
+ _anchorIndex = index;
+}
+
+// Shift-click range: select every track from the anchor to `toIndex`
+// inclusive. If no anchor is set (first click ever in this list), the
+// range collapses to a single toggle.
+export function selectRange(toIndex: number, tracks: TrackRef[]): void {
+ if (_anchorIndex === null) {
+ toggleOne(tracks[toIndex], toIndex);
+ return;
+ }
+ const from = _anchorIndex;
+ const [a, b] = from <= toIndex ? [from, toIndex] : [toIndex, from];
+ const ids = new Set(_selectedIds);
+ const map = new Map(_selectedTrackMap);
+ for (let i = a; i <= b; i++) {
+ const t = tracks[i];
+ if (!t) continue;
+ ids.add(t.id);
+ map.set(t.id, t);
+ }
+ _selectedIds = ids;
+ _selectedTrackMap = map;
+ _anchorIndex = toIndex;
+}
+
+export function clearSelection(): void {
+ if (_selectedIds.size === 0 && _anchorIndex === null) return;
+ _selectedIds = new Set();
+ _selectedTrackMap = new Map();
+ _anchorIndex = null;
+}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 07d2572f..505e5946 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -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}
+
+