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:
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user