feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s
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:
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { selection, clearSelection } from '$lib/selection/store.svelte';
|
||||
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
|
||||
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const likedQ = $derived(createLikedIdsQuery());
|
||||
const likedIds = $derived($likedQ?.data?.track_ids ?? []);
|
||||
|
||||
let addOpen = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
function onPlayNext() {
|
||||
playNextMany(selection.tracks);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function onAddQueue() {
|
||||
enqueueTracks(selection.tracks);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onLikeAll() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const likedSet = new Set(likedIds);
|
||||
const toLike = selection.tracks.filter((t) => !likedSet.has(t.id));
|
||||
for (const t of toLike) {
|
||||
await likeEntity(queryClient, 'track', t.id);
|
||||
}
|
||||
clearSelection();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
addOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if selection.active}
|
||||
<!-- Sits above PlayerBar (z-30 < 40). Anchors to the viewport bottom
|
||||
with bottom-24 so PlayerBar's ~80px shell stays visible. The bar
|
||||
is dismissed by the × button or by Escape (handled in layout). -->
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Selection actions"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-24 z-30 flex justify-center px-4"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-2 rounded-full border border-border
|
||||
bg-surface px-3 py-2 shadow-lg"
|
||||
>
|
||||
<span class="px-2 text-sm text-text-primary">
|
||||
{selection.count} selected
|
||||
</span>
|
||||
<span class="h-5 w-px bg-border"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayNext}
|
||||
aria-label="Play selected next"
|
||||
title="Play next"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ListPlus size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Play next</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddQueue}
|
||||
aria-label="Add selected to queue"
|
||||
title="Add to queue"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Add to queue</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addOpen = !addOpen)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addOpen}
|
||||
aria-label="Add selected to playlist"
|
||||
title="Add to playlist"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ListMusic size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Add to playlist</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLikeAll}
|
||||
disabled={busy}
|
||||
aria-label="Like all selected"
|
||||
title="Like all"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
>
|
||||
<Heart size={16} strokeWidth={1.5} />
|
||||
<span class="hidden sm:inline">Like all</span>
|
||||
</button>
|
||||
|
||||
<span class="h-5 w-px bg-border"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
class="rounded p-1.5 text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addOpen}
|
||||
<div class="pointer-events-auto absolute bottom-full mb-2">
|
||||
<AddToPlaylistMenu tracks={selection.tracks} {onClose} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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' : ''}"
|
||||
>
|
||||
<span class="text-right tabular-nums text-text-secondary">
|
||||
{track.track_number ?? '—'}
|
||||
<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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
const resp = await api.get<RadioResponse>(
|
||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>());
|
||||
let _selectedTrackMap = $state(new Map<string, TrackRef>());
|
||||
let _anchorIndex = $state<number | null>(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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user