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} + +