Files
minstrel/web/src/lib/components/QueueList.svelte
T
bvandeusenandClaude Opus 4.8 cde74b5965
test-web / test (push) Successful in 40s
feat(player): web queue auto-follow + jump-to-current pill + clear-queue — #1944
QueueList now follows the now-playing row as the track auto-advances (only
while it's in view), centers it on open, and surfaces a 'Jump to current' pill
once the user scrolls it off-screen. Header gains a clear-queue action backed
by a new store clearQueue() that empties the queue and stops playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:57:23 -04:00

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';
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>