feat(player): web queue auto-follow + jump-to-current pill + clear-queue — #1944
test-web / test (push) Successful in 40s
test-web / test (push) Successful in 40s
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>
This commit is contained in:
@@ -25,10 +25,12 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
get queueDrawerOpen() { return openValue; }
|
get queueDrawerOpen() { return openValue; }
|
||||||
},
|
},
|
||||||
// QueueTrackRow imports these from the store; provide stubs so its
|
// QueueTrackRow imports these from the store; provide stubs so its
|
||||||
// module-load doesn't break when QueueDrawer renders rows.
|
// module-load doesn't break when QueueDrawer renders rows. QueueList
|
||||||
|
// imports clearQueue for its header action.
|
||||||
playFromQueueIndex: vi.fn(),
|
playFromQueueIndex: vi.fn(),
|
||||||
removeFromQueue: vi.fn(),
|
removeFromQueue: vi.fn(),
|
||||||
moveQueueItem: vi.fn()
|
moveQueueItem: vi.fn(),
|
||||||
|
clearQueue: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import QueueDrawer from './QueueDrawer.svelte';
|
import QueueDrawer from './QueueDrawer.svelte';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { X } from 'lucide-svelte';
|
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { player, clearQueue } from '$lib/player/store.svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
// onClose: when provided, renders an X button in the header so the
|
// onClose: when provided, renders an X button in the header so the
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||||
// keyboard users on open.
|
// keyboard users on open.
|
||||||
// active: true when the queue is on-screen (drawer open, or the always-
|
// active: true when the queue is on-screen (drawer open, or the always-
|
||||||
// visible now-playing panel). Flipping it true scrolls the now-playing
|
// visible now-playing panel). Gates the scroll-to-current behavior.
|
||||||
// row into view — parity with the Android queue, which opens positioned
|
|
||||||
// on the current track.
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeButtonRef?: HTMLButtonElement;
|
closeButtonRef?: HTMLButtonElement;
|
||||||
@@ -22,19 +20,54 @@
|
|||||||
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
||||||
|
|
||||||
let scrollBody: HTMLElement | undefined = $state();
|
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;
|
||||||
|
|
||||||
// When the queue becomes visible, center the now-playing row in view. The
|
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
|
||||||
// index/length are read untracked so this fires once per open (matching the
|
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
|
||||||
// Android queue's open-positioned behavior) rather than following the track
|
block,
|
||||||
// as it auto-advances. Deferred a frame so the drawer's slide-in has settled.
|
behavior,
|
||||||
$effect(() => {
|
|
||||||
if (!active || !scrollBody) return;
|
|
||||||
const body = scrollBody;
|
|
||||||
const index = untrack(() => player.index);
|
|
||||||
if (untrack(() => player.queue.length) === 0) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
(body.children[index] as HTMLElement | undefined)?.scrollIntoView({ block: 'center' });
|
|
||||||
});
|
});
|
||||||
|
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 {
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||||
@@ -45,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="relative flex h-full flex-col">
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Queue</h2>
|
<h2 class="text-lg font-semibold">Queue</h2>
|
||||||
@@ -54,20 +87,33 @@
|
|||||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if onClose}
|
<div class="flex items-center gap-1">
|
||||||
<button
|
{#if player.queue.length > 0}
|
||||||
type="button"
|
<button
|
||||||
bind:this={closeButtonRef}
|
type="button"
|
||||||
aria-label="Close queue"
|
aria-label="Clear queue"
|
||||||
onclick={onClose}
|
title="Clear queue"
|
||||||
class="text-text-secondary hover:text-text-primary"
|
onclick={() => clearQueue()}
|
||||||
>
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||||
<X size={20} />
|
>
|
||||||
</button>
|
<Trash2 size={18} />
|
||||||
{/if}
|
</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>
|
||||||
|
|
||||||
<div bind:this={scrollBody} class="flex-1 overflow-y-auto">
|
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
|
||||||
{#if player.queue.length === 0}
|
{#if player.queue.length === 0}
|
||||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -76,4 +122,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
|
|||||||
_error = null;
|
_error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the whole queue and stop playback — mirrors removeFromQueue's
|
||||||
|
// empty-queue branch. Also drops the radio/system source + self-heal closure
|
||||||
|
// so the emptied player doesn't try to refill from a now-irrelevant source.
|
||||||
|
export function clearQueue(): void {
|
||||||
|
_queue = [];
|
||||||
|
_index = 0;
|
||||||
|
_state = 'idle';
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_error = null;
|
||||||
|
_radioSeedId = null;
|
||||||
|
_queueSource = null;
|
||||||
|
_queueRefetch = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function playFromQueueIndex(idx: number): void {
|
export function playFromQueueIndex(idx: number): void {
|
||||||
if (idx < 0 || idx >= _queue.length) return;
|
if (idx < 0 || idx >= _queue.length) return;
|
||||||
_radioSeedId = null;
|
_radioSeedId = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user