74bae74f9f
test-web / test (push) Failing after 45s
PlaylistCard: per-artist system variants (songs_like_artist) all share one variant tag but exist as one playlist per seed artist; routing their play handler through systemShuffle(variant) hit the wrong playlist (or 404). Detect via seed_artist_id != null and fall through to getPlaylist(playlist.id) for those; still tag the queue with the variant so source attribution stays correct. smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors Android's rememberSmoothPositionMs. player.position only updates ~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly; $effect resets on each canonical tick / seek / track-change and a rAF loop extrapolates at playback rate between ticks. Wired into both PlayerBar.svelte (mini + expanded seek rows) and now-playing/+page.svelte. Seek input handler still reads the raw range value (not smoothed.value) so user drags stay authoritative.
435 lines
18 KiB
Svelte
435 lines
18 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
SkipBack, SkipForward, Play, Pause, Loader2,
|
|
Shuffle, Repeat, Repeat1,
|
|
Volume2, Volume1, VolumeX,
|
|
ListMusic
|
|
} from 'lucide-svelte';
|
|
import {
|
|
player,
|
|
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
|
toggleShuffle, cycleRepeat, playQueue,
|
|
toggleQueueDrawer
|
|
} from '$lib/player/store.svelte';
|
|
import { formatDuration } from '$lib/media/duration';
|
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
|
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
|
import LikeButton from './LikeButton.svelte';
|
|
import TrackMenu from './TrackMenu.svelte';
|
|
|
|
const current = $derived(player.current);
|
|
|
|
// Per-frame interpolated playhead so the scrubber bar moves
|
|
// smoothly between server ticks instead of stepping every ~250ms.
|
|
// Reset on track/play-state change via the helper's $effect.
|
|
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;
|
|
}
|
|
|
|
// #9 — dominant-color accent. Sample the current track's cover and
|
|
// pipe it into a CSS custom property; a 2px strip above the bar
|
|
// takes on the colour, so the page subtly tracks what's playing.
|
|
// Cache-backed in dominantColorFromUrl so revisits are free.
|
|
// Default ('') keeps the strip transparent until the first cover
|
|
// resolves.
|
|
let accentRgb = $state('');
|
|
$effect(() => {
|
|
const cover = current?.album_id ? coverUrl(current.album_id) : null;
|
|
if (!cover) {
|
|
accentRgb = '';
|
|
return;
|
|
}
|
|
dominantColorFromUrl(cover).then((rgb) => {
|
|
if (rgb) accentRgb = rgbToCssString(rgb);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
{#if current}
|
|
<!--
|
|
COMPACT view (below md). Layout per operator design:
|
|
┌─────────┬───────────┬───────────────────┐
|
|
│ art │ ♥ ⋮ │ 🔀 🔁 ☰ │ ← short sub-row
|
|
│ title │ │ │
|
|
│ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play row at full size
|
|
├─────────┴───────────┴───────────────────┤
|
|
│ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
|
|
└─────────────────────────────────────────┘
|
|
Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows
|
|
are shorter so column 2's bottom edge aligns with column 3's bottom.
|
|
-->
|
|
<!-- Dominant-color accent strip — same colour for both compact
|
|
and desktop variants, only one renders at a time due to the
|
|
md: breakpoint switch below. Transparent until the first
|
|
cover resolves; 300ms tween on colour change so track-skips
|
|
fade rather than snap. -->
|
|
<div
|
|
aria-hidden="true"
|
|
class="md:hidden h-[2px] w-full transition-colors duration-300"
|
|
style={accentRgb ? `background-color: ${accentRgb};` : ''}
|
|
></div>
|
|
<div
|
|
data-testid="player-bar-compact"
|
|
class="md:hidden flex flex-col gap-1.5 border-t border-border bg-surface px-3 py-2"
|
|
>
|
|
{#if player.state === 'error'}
|
|
<div role="alert" class="flex items-center justify-center gap-3 text-sm text-text-primary">
|
|
<span>{player.error ?? 'Playback failed.'}</span>
|
|
<button
|
|
type="button"
|
|
class="rounded bg-accent px-3 py-1.5 text-sm text-text-primary"
|
|
onclick={() => playQueue(player.queue, player.index)}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<!-- Top row: 3 columns -->
|
|
<div class="flex items-stretch gap-3">
|
|
<!-- Column 1: art + title + artist. Tap target opens the
|
|
full-screen NowPlaying view (Spotify/YouTube Music pattern).
|
|
Album reachable via the album-name link inside NowPlaying. -->
|
|
<a
|
|
href="/now-playing"
|
|
aria-label="Open now playing"
|
|
class="flex min-w-0 items-center gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
|
style="flex: 1 1 35%;"
|
|
>
|
|
<img
|
|
src={coverUrl(current.album_id)}
|
|
alt=""
|
|
class="h-12 w-12 shrink-0 rounded-md object-cover"
|
|
onerror={onCoverError}
|
|
/>
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
|
|
<div class="truncate text-xs text-text-secondary">{current.artist_name}</div>
|
|
</div>
|
|
</a>
|
|
|
|
<!-- Column 2: like+kebab (top), play controls (bottom) -->
|
|
<div class="flex flex-col items-center justify-between gap-1 shrink-0">
|
|
<div class="flex items-center gap-0.5">
|
|
<LikeButton entityType="track" entityId={current.id} />
|
|
<TrackMenu track={current} direction="up" hideQueueActions />
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
aria-label="Previous"
|
|
disabled={skipPrevDisabled}
|
|
onclick={skipPrev}
|
|
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
<SkipBack size={18} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
|
onclick={togglePlay}
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
{#if player.state === 'loading' || player.state === 'idle'}
|
|
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
|
|
{:else if player.isPlaying}
|
|
<Pause size={22} strokeWidth={1.5} fill="currentColor" />
|
|
{:else}
|
|
<Play size={22} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Next"
|
|
disabled={skipNextDisabled}
|
|
onclick={skipNext}
|
|
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
<SkipForward size={18} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Column 3: shuffle/repeat/queue (top), volume slider (bottom) -->
|
|
<div class="flex min-w-0 flex-col items-stretch justify-between gap-1" style="flex: 1 1 30%;">
|
|
<div class="flex items-center justify-end gap-0.5">
|
|
<button
|
|
type="button"
|
|
aria-label="Shuffle"
|
|
aria-pressed={player.shuffle}
|
|
onclick={toggleShuffle}
|
|
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.shuffle
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
<Shuffle size={16} strokeWidth={1.5} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={repeatLabel}
|
|
onclick={cycleRepeat}
|
|
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
{#if player.repeat === 'one'}
|
|
<Repeat1 size={16} strokeWidth={1.5} />
|
|
{:else}
|
|
<Repeat size={16} strokeWidth={1.5} />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleQueueDrawer()}
|
|
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
|
|
aria-pressed={player.queueDrawerOpen}
|
|
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
<ListMusic size={16} strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
<label class="flex items-center gap-1">
|
|
<span class="sr-only">Volume</span>
|
|
<VolumeIcon size={14} strokeWidth={1.5} class="shrink-0 text-text-secondary" />
|
|
<input
|
|
type="range"
|
|
aria-label="Volume"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={player.volume}
|
|
oninput={onVolumeInput}
|
|
class="min-w-0 flex-1 accent-accent"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom row: full-width seek slider + time labels.
|
|
Reads the smoothed position so the slider thumb glides
|
|
between server ticks at playback rate. -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
|
{formatDuration(smoothed.value)}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
aria-label="Seek"
|
|
min="0"
|
|
max={player.duration || 0}
|
|
step="0.1"
|
|
value={smoothed.value}
|
|
oninput={onSeekInput}
|
|
class="flex-1 accent-accent"
|
|
/>
|
|
<span class="w-9 shrink-0 text-[10px] tabular-nums text-text-secondary">
|
|
{formatDuration(player.duration)}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!--
|
|
DESKTOP view (md+). Original 3-column layout — unchanged except that
|
|
the redundant PlayerOverflowMenu kebab is no longer mounted (volume,
|
|
shuffle, repeat, queue all live inline in the right cluster).
|
|
-->
|
|
<!-- See compact-variant accent strip above; desktop has its own
|
|
so both responsive sizes get the same treatment. -->
|
|
<div
|
|
aria-hidden="true"
|
|
class="hidden md:block h-[2px] w-full transition-colors duration-300"
|
|
style={accentRgb ? `background-color: ${accentRgb};` : ''}
|
|
></div>
|
|
<div
|
|
data-testid="player-bar-desktop"
|
|
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
|
>
|
|
<!-- Left: cover + title + artist + like + menu -->
|
|
<div class="flex w-72 min-w-0 items-center gap-3">
|
|
<a
|
|
href="/now-playing"
|
|
aria-label="Open now playing"
|
|
class="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
|
>
|
|
<img
|
|
src={coverUrl(current.album_id)}
|
|
alt=""
|
|
class="h-24 w-24 rounded-md object-cover"
|
|
onerror={onCoverError}
|
|
/>
|
|
</a>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
|
|
<a
|
|
href={`/artists/${current.artist_id}`}
|
|
class="block truncate text-xs text-text-secondary hover:text-text-primary"
|
|
>
|
|
{current.artist_name}
|
|
</a>
|
|
{#if player.queue.length > player.index + 1}
|
|
{@const nextTrack = player.queue[player.index + 1]}
|
|
<span class="block truncate text-xs text-text-secondary">
|
|
Next · {nextTrack.title} — {nextTrack.artist_name}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<LikeButton entityType="track" entityId={current.id} />
|
|
<TrackMenu track={current} direction="up" hideQueueActions />
|
|
</div>
|
|
|
|
<!-- Center: seek row + transport row -->
|
|
{#if player.state === 'error'}
|
|
<div role="alert" class="flex flex-1 items-center justify-center gap-3 text-sm text-text-primary">
|
|
<span>{player.error ?? 'Playback failed.'}</span>
|
|
<button
|
|
type="button"
|
|
class="rounded bg-accent px-3 py-1.5 text-sm text-text-primary"
|
|
onclick={() => playQueue(player.queue, player.index)}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-1 flex-col items-stretch gap-2">
|
|
<!-- Transport row -->
|
|
<div class="flex items-center justify-center gap-3">
|
|
<button
|
|
type="button"
|
|
aria-label="Previous"
|
|
disabled={skipPrevDisabled}
|
|
onclick={skipPrev}
|
|
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
<SkipBack size={20} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
|
onclick={togglePlay}
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
{#if player.state === 'loading' || player.state === 'idle'}
|
|
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
|
|
{:else if player.isPlaying}
|
|
<Pause size={24} strokeWidth={1.5} fill="currentColor" />
|
|
{:else}
|
|
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Next"
|
|
disabled={skipNextDisabled}
|
|
onclick={skipNext}
|
|
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
|
</button>
|
|
</div>
|
|
<!-- Seek row — reads smoothed position so the thumb glides
|
|
between server ticks. See useSmoothPosition. -->
|
|
<div class="flex items-center gap-3">
|
|
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
|
{formatDuration(smoothed.value)}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
aria-label="Seek"
|
|
min="0"
|
|
max={player.duration || 0}
|
|
step="0.1"
|
|
value={smoothed.value}
|
|
oninput={onSeekInput}
|
|
class="flex-1 accent-accent"
|
|
/>
|
|
<span class="w-12 shrink-0 text-xs tabular-nums text-text-secondary">
|
|
{formatDuration(player.duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Right: shuffle + repeat + queue + volume -->
|
|
<div class="flex w-60 items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
aria-label="Shuffle"
|
|
aria-pressed={player.shuffle}
|
|
onclick={toggleShuffle}
|
|
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.shuffle
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
<Shuffle size={18} strokeWidth={1.5} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={repeatLabel}
|
|
onclick={cycleRepeat}
|
|
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
{#if player.repeat === 'one'}
|
|
<Repeat1 size={18} strokeWidth={1.5} />
|
|
{:else}
|
|
<Repeat size={18} strokeWidth={1.5} />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleQueueDrawer()}
|
|
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
|
|
aria-pressed={player.queueDrawerOpen}
|
|
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
|
|
? 'bg-accent-tint text-accent'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
|
|
>
|
|
<ListMusic size={18} strokeWidth={1.5} />
|
|
</button>
|
|
<label class="flex items-center gap-1.5">
|
|
<span class="sr-only">Volume</span>
|
|
<VolumeIcon size={18} strokeWidth={1.5} class="shrink-0 text-text-secondary" />
|
|
<input
|
|
type="range"
|
|
aria-label="Volume"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={player.volume}
|
|
oninput={onVolumeInput}
|
|
class="w-20 accent-accent"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{/if}
|