Files
minstrel/web/src/routes/now-playing/+page.svelte
T
bvandeusen 15a545a25c
test-web / test (push) Failing after 33s
feat(web): two-pane now-playing on desktop with permanent queue panel
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:

- Extract QueueList from QueueDrawer (header + count + scrollable
  list body + empty state); the drawer now wraps QueueList for its
  slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
  (xl: 448px) aside with QueueList. Below lg the layout is unchanged
  single-column and the drawer toggle in the bottom row still opens
  it (lg:hidden on the toggle button keeps it out of the way once
  the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
  and the bind:closeButtonRef for focus management; embedded panel
  omits both since it has no dismiss action.

QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
2026-06-01 23:24:06 -04:00

262 lines
9.1 KiB
Svelte

<script lang="ts">
import {
ArrowLeft, SkipBack, SkipForward, Play, Pause, Loader2,
Shuffle, Repeat, Repeat1,
Volume2, Volume1, VolumeX,
ListMusic
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import {
player,
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat,
toggleQueueDrawer
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding';
const current = $derived(player.current);
// Per-frame interpolated playhead so the scrubber thumb glides
// smoothly between server ticks. Shares the helper with PlayerBar.
const smoothed = useSmoothPosition();
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
);
const repeatLabel = $derived(
player.repeat === 'off' ? 'Repeat off' :
player.repeat === 'all' ? 'Repeat all' : 'Repeat one'
);
const VolumeIcon = $derived(
player.volume === 0 ? VolumeX :
player.volume < 0.5 ? Volume1 : Volume2
);
function onSeekInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
seekTo(v);
}
function onVolumeInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
setVolume(v);
}
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
function goBack() {
// Most users land here from the PlayerBar — history.back() returns
// them to whatever surface they were on. If there's nothing to go
// back to (deep link), fall through to Home.
if (window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
</script>
<svelte:head><title>{pageTitle(current ? `${current.title} · ${current.artist_name}` : 'Now playing')}</title></svelte:head>
<div class="flex h-screen flex-col bg-background text-text-primary">
<header class="flex items-center px-4 py-3">
<button
type="button"
onclick={goBack}
aria-label="Back"
class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded
text-text-secondary hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
<ArrowLeft size={22} strokeWidth={1.5} />
</button>
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
</header>
{#if !current}
<div class="flex flex-1 items-center justify-center px-4 text-center">
<p class="text-text-secondary">
Nothing playing yet. Pick something from
<a href="/" class="text-text-primary underline">Home</a>
or
<a href="/library" class="text-text-primary underline">your Library</a>.
</p>
</div>
{:else}
<!-- At lg+ the player splits into two panes: the focus column on
the left and a permanent Queue panel on the right. Below lg
the queue stays in the slide-in QueueDrawer (toggle button in
the bottom row). Wide-screen viewers get queue context without
opening a drawer; mobile keeps the focused single-column flow. -->
<main class="flex flex-1 overflow-hidden">
<section class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6 overflow-y-auto">
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
<img
src={coverUrl(current.album_id)}
alt=""
class="h-full w-full object-cover"
onerror={onCoverError}
/>
</div>
<div class="w-full max-w-md text-center">
<h1 class="font-display text-2xl font-medium leading-tight">
{current.title}
</h1>
<p class="mt-1 text-sm">
<a
href={`/artists/${current.artist_id}`}
class="text-text-secondary hover:text-text-primary hover:underline"
>
{current.artist_name}
</a>
{#if current.album_title}
<span class="text-text-secondary"> · </span>
<a
href={`/albums/${current.album_id}`}
class="text-text-secondary hover:text-text-primary hover:underline"
>
{current.album_title}
</a>
{/if}
</p>
</div>
<div class="w-full max-w-md">
<input
type="range"
min="0"
max={player.duration || 0}
step="0.1"
value={smoothed.value}
oninput={onSeekInput}
aria-label="Seek"
class="w-full accent-accent"
/>
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
<span>{formatDuration(smoothed.value)}</span>
<span>{formatDuration(player.duration)}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={toggleShuffle}
aria-label="Shuffle"
aria-pressed={player.shuffle}
class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded
{player.shuffle ? 'text-accent' : 'text-text-secondary hover:text-text-primary'}
focus-visible:ring-2 focus-visible:ring-accent"
>
<Shuffle size={20} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={skipPrev}
disabled={skipPrevDisabled}
aria-label="Previous"
class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded
text-text-primary disabled:text-text-secondary disabled:cursor-not-allowed
focus-visible:ring-2 focus-visible:ring-accent"
>
<SkipBack size={26} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={togglePlay}
aria-label={player.state === 'playing' ? 'Pause' : 'Play'}
class="flex items-center justify-center min-h-[56px] min-w-[56px] rounded-full
bg-text-primary text-background
focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading'}
<Loader2 size={28} strokeWidth={2} class="animate-spin" />
{:else if player.state === 'playing'}
<Pause size={28} strokeWidth={2} />
{:else}
<Play size={28} strokeWidth={2} />
{/if}
</button>
<button
type="button"
onclick={skipNext}
disabled={skipNextDisabled}
aria-label="Next"
class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded
text-text-primary disabled:text-text-secondary disabled:cursor-not-allowed
focus-visible:ring-2 focus-visible:ring-accent"
>
<SkipForward size={26} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={cycleRepeat}
aria-label={repeatLabel}
aria-pressed={player.repeat !== 'off'}
class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded
{player.repeat !== 'off' ? 'text-accent' : 'text-text-secondary hover:text-text-primary'}
focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.repeat === 'one'}
<Repeat1 size={20} strokeWidth={1.5} />
{:else}
<Repeat size={20} strokeWidth={1.5} />
{/if}
</button>
</div>
<div class="flex w-full max-w-md items-center justify-between gap-4">
<div class="flex items-center gap-2">
<LikeButton entityType="track" entityId={current.id} />
</div>
<div class="flex flex-1 items-center gap-2 px-4">
<VolumeIcon size={18} strokeWidth={1.5} class="text-text-secondary" />
<input
type="range"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
aria-label="Volume"
class="flex-1 accent-accent"
/>
</div>
<button
type="button"
onclick={toggleQueueDrawer}
aria-label="Open queue"
class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px]
text-text-secondary hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent
lg:hidden"
>
<ListMusic size={20} strokeWidth={1.5} />
<span class="text-sm tabular-nums">{player.queue.length}</span>
</button>
</div>
</section>
<aside
aria-label="Queue"
class="hidden lg:flex w-96 xl:w-[28rem] flex-col border-l border-border bg-surface"
>
<QueueList />
</aside>
</main>
{/if}
</div>