feat(web): full-screen NowPlaying view
test-web / test (push) Failing after 30s

Mirrors Android's NowPlayingScreen. New /now-playing route renders
outside the Shell (no top bar, no PlayerBar) so it's a focused full
viewport: large square cover, title + linked artist/album, full-width
scrubber with timestamps, prominent transport (prev / play-pause /
next, with a 56dp circular play button), shuffle + repeat toggles,
like button, volume slider, and a queue button that opens the
existing queue drawer.

Tapping the cover or title area in PlayerBar (compact + desktop)
now navigates to /now-playing instead of the album page. Pattern
matches Spotify / YouTube Music. The album link stays reachable via
the album-name link inside NowPlaying itself, so no nav is lost.

Back button in the NowPlaying header uses history.back() with a
fallback to /. Empty state when no track is loaded points the user
at Home or the Library.

Scribe 528, local task #62.
This commit is contained in:
2026-06-01 11:30:07 -04:00
parent 5393174a27
commit a2466b7d9a
3 changed files with 249 additions and 6 deletions
+1 -1
View File
@@ -151,7 +151,7 @@
<QueueDrawer />
<QueryClientProvider client={queryClient}>
{#if user.value !== null && page.url.pathname !== '/login'}
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
<Shell>{@render children()}</Shell>
{:else}
{@render children()}
+241
View File
@@ -0,0 +1,241 @@
<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 LikeButton from '$lib/components/LikeButton.svelte';
import { pageTitle } from '$lib/branding';
const current = $derived(player.current);
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}
<main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6">
<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={player.position}
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(player.position)}</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"
>
<ListMusic size={20} strokeWidth={1.5} />
<span class="text-sm tabular-nums">{player.queue.length}</span>
</button>
</div>
</main>
{/if}
</div>