Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
5.0 KiB
Svelte
139 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
|
import { player, clearQueue } from '#lib/player/store.svelte.js';
|
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
|
|
|
// onClose: when provided, renders an X button in the header so the
|
|
// slide-in drawer can dismiss itself. The embedded panel on the
|
|
// now-playing route (visible at lg+ widths) omits it.
|
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
|
// keyboard users on open.
|
|
// active: true when the queue is on-screen (drawer open, or the always-
|
|
// visible now-playing panel). Gates the scroll-to-current behavior.
|
|
type Props = {
|
|
onClose?: () => void;
|
|
closeButtonRef?: HTMLButtonElement;
|
|
active?: boolean;
|
|
};
|
|
|
|
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
|
|
|
let scrollBody: HTMLElement | undefined = $state();
|
|
// Whether the now-playing row is (at least partly) within the scroll
|
|
// viewport. Drives auto-follow (only follow while the user is watching the
|
|
// current track) and the "Jump to current" pill (shown when it's off-screen).
|
|
let currentInView = $state(true);
|
|
let sawFirstIndex = false;
|
|
|
|
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
|
|
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
|
|
block,
|
|
behavior,
|
|
});
|
|
currentInView = true;
|
|
}
|
|
|
|
function recomputeInView() {
|
|
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
|
|
if (!scrollBody || !row) {
|
|
currentInView = true;
|
|
return;
|
|
}
|
|
const b = scrollBody.getBoundingClientRect();
|
|
const r = row.getBoundingClientRect();
|
|
currentInView = r.bottom > b.top && r.top < b.bottom;
|
|
}
|
|
|
|
// On open (active flips true, or on mount for the always-visible panel),
|
|
// center the now-playing row — parity with the Android queue.
|
|
$effect(() => {
|
|
if (!active) return;
|
|
if (untrack(() => player.queue.length) === 0) return;
|
|
requestAnimationFrame(() => scrollToCurrent('center'));
|
|
});
|
|
|
|
// Follow the current track as it auto-advances, but only while the user is
|
|
// still watching it — if they've scrolled away, leave them there (the pill
|
|
// offers the way back). block:'nearest' keeps it minimal (no yank when the
|
|
// row is already visible). Index is tracked; currentInView is read untracked
|
|
// so a scroll that hides the row doesn't itself re-trigger a scroll.
|
|
$effect(() => {
|
|
player.index; // subscribe: follow on advance
|
|
if (!sawFirstIndex) {
|
|
sawFirstIndex = true;
|
|
return; // the open effect already handled the initial position
|
|
}
|
|
if (!active) return;
|
|
if (untrack(() => player.queue.length) === 0) return;
|
|
if (!untrack(() => currentInView)) return;
|
|
requestAnimationFrame(() => scrollToCurrent('nearest'));
|
|
});
|
|
|
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
|
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
|
const m = Math.floor(totalSec / 60);
|
|
const h = Math.floor(m / 60);
|
|
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
|
|
}
|
|
</script>
|
|
|
|
<div class="relative flex h-full flex-col">
|
|
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
<div>
|
|
<h2 class="text-lg font-semibold">Queue</h2>
|
|
<p class="text-xs text-text-secondary">
|
|
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
{#if player.queue.length > 0}
|
|
<button
|
|
type="button"
|
|
aria-label="Clear queue"
|
|
title="Clear queue"
|
|
onclick={() => clearQueue()}
|
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
{/if}
|
|
{#if onClose}
|
|
<button
|
|
type="button"
|
|
bind:this={closeButtonRef}
|
|
aria-label="Close queue"
|
|
onclick={onClose}
|
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
|
|
{#if player.queue.length === 0}
|
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
|
{:else}
|
|
{#each player.queue as track, i (track.id)}
|
|
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if active && player.queue.length > 0 && !currentInView}
|
|
<button
|
|
type="button"
|
|
onclick={() => scrollToCurrent('center', 'smooth')}
|
|
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
|
|
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
|
|
text-action-fg shadow-lg"
|
|
>
|
|
<ArrowDown size={14} />
|
|
Jump to current
|
|
</button>
|
|
{/if}
|
|
</div>
|