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).
126 lines
4.1 KiB
Svelte
126 lines
4.1 KiB
Svelte
<script lang="ts">
|
|
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, 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;
|
|
|
|
let {
|
|
tracks,
|
|
index,
|
|
onPlay
|
|
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();
|
|
|
|
const track = $derived(tracks[index]);
|
|
|
|
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(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();
|
|
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);
|
|
}
|
|
|
|
function onRadioClick(e: MouseEvent) {
|
|
e.stopPropagation();
|
|
playRadio(track.id);
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label={track.title}
|
|
data-selected={isSelected}
|
|
onclick={onRowClick}
|
|
onkeydown={onRowKey}
|
|
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' : ''}"
|
|
>
|
|
<span class="relative flex h-6 w-8 items-center justify-end">
|
|
<!-- Track number: visible by default; hidden when the row is hovered
|
|
(so the checkbox replaces it) or when this row is selected. -->
|
|
<span
|
|
class="tabular-nums text-text-secondary
|
|
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
|
|
>{track.track_number ?? '—'}</span>
|
|
<!-- Checkbox: hidden by default; revealed on row hover OR whenever
|
|
the selection bar is active OR this row is the selected one. -->
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
aria-checked={isSelected}
|
|
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
|
|
onclick={onCheckboxClick}
|
|
class="flex h-5 w-5 items-center justify-center rounded border
|
|
{isSelected
|
|
? 'border-accent bg-accent text-text-primary'
|
|
: 'border-border bg-surface text-transparent'}
|
|
{selectMode || isSelected ? 'flex' : 'hidden group-hover:flex'}"
|
|
>
|
|
<Check size={14} strokeWidth={2} />
|
|
</button>
|
|
</span>
|
|
<span class="truncate">{track.title}</span>
|
|
<LikeButton entityType="track" entityId={track.id} />
|
|
<button
|
|
type="button"
|
|
aria-label="Play radio from this track"
|
|
title="Play radio"
|
|
onclick={onRadioClick}
|
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
>📻</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`Add ${track.title} to queue`}
|
|
title="Add to queue"
|
|
onclick={onAddClick}
|
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
>
|
|
<Plus size={16} strokeWidth={1} />
|
|
</button>
|
|
<TrackMenu {track} />
|
|
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
|
|
</div>
|