Files
minstrel/web/src/lib/components/PlayerBar.svelte
T
bvandeusen 066616e196
test-web / test (push) Successful in 40s
fix(web): de-overlap Most Played hover controls + narrow desktop seek bar
CompactTrackCard is a short one-line row but reused CardActionCluster,
which corner-splits Like+Add (top) and the kebab menu (bottom). That split
is right for the tall square Album/Artist cards but makes the two groups
collide on the compact row's hover state. Give the compact card a single
inline, vertically-centred right cluster (Like + Add + menu in one group)
and widen its right padding reserve to match.

In the desktop PlayerBar, the left info column was a fixed w-72 (title kept
truncating) while the seek column was flex-1 (the scrubber hogged the slack
on wide screens). Let the left column grow up to max-w-md while holding its
288px floor at md, and cap the seek/transport column at max-w-xl centred, so
freed width flows to the title instead of stretching the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:26 -04:00

442 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.
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
(where the seek column is the flex-1 that absorbs the remainder), but
on wider screens it grows up to 448px instead of leaving the title
truncated while the seek bar hogs the slack. -->
<div class="flex basis-72 grow max-w-md 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}
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
576px and centres it, so on wide screens the extra width flows to
the title column on the left rather than stretching the scrubber. -->
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
<!-- 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}