feat(web): redesign PlayerBar — Lucide icons, focal play button, taller bar

Three theming/density problems with the old PlayerBar:

- Emoji glyphs (⏮ ▶ ⏸ ⏭ 🔀 🔁 ◌) painted at OS-default sizes inside p-1
  hit areas, so each button had a 6-8px ring of dead space and the row
  clashed with the Lucide stroke-icon language used elsewhere.
- No focal point on transport — play looked structurally identical to
  prev/next so the eye had nothing to anchor on.
- Cover at 48x48 in a 72px bar left visible vertical breathing room
  around it that read as accidental empty space.

Changes:

- All emoji replaced with Lucide icons: SkipBack, Play, Pause, Loader2,
  SkipForward, Shuffle, Repeat, Repeat1, Volume2/Volume1/VolumeX.
- Play button is the brand moment: 48px circular, accent forest-teal
  background, 24px parchment icon. Prev/next stay minimal at 40px
  circular with hover-bg.
- Cover bumped to 80x80 in a min-h-[108px] bar (~1.5x the previous
  height) so the player reads as substantial rather than a thin strip.
- Shuffle / repeat get consistent 36px circular hit areas with
  bg-accent-tint + text-accent active state instead of color-only
  toggling. Repeat1 icon swap on repeat-one mode replaces the old
  '🔁¹' string concatenation.
- Volume slider gets a state-aware Lucide icon to its left
  (VolumeX / Volume1 / Volume2 by level), giving the right column a
  visual anchor.
- Right column tightens from w-56 to w-48 since icons are denser than
  emoji + text.

TrackMenu gains a 'direction' prop ('up' | 'down', default 'down') so
PlayerBar can pass direction='up' — the kebab menu opens upward instead
of off the bottom of the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 23:28:10 -04:00
parent 564e0bf7ca
commit 8c62f0089e
2 changed files with 90 additions and 47 deletions
+80 -45
View File
@@ -1,4 +1,9 @@
<script lang="ts">
import {
SkipBack, SkipForward, Play, Pause, Loader2,
Shuffle, Repeat, Repeat1,
Volume2, Volume1, VolumeX
} from 'lucide-svelte';
import {
player,
togglePlay, skipNext, skipPrev, seekTo, setVolume,
@@ -21,6 +26,12 @@
player.repeat === 'all' ? 'Repeat all' : 'Repeat one'
);
// Volume icon tracks level so the right-edge anchor reads at a glance.
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);
@@ -35,9 +46,9 @@
</script>
{#if current}
<div class="flex items-center gap-4 border-t border-border bg-surface px-4 py-3">
<!-- Left: cover + title + artist -->
<div class="flex w-60 min-w-0 items-center gap-3">
<div class="flex min-h-[108px] items-center gap-4 border-t border-border bg-surface px-4 py-4">
<!-- Left: cover + title + artist + like + menu -->
<div class="flex w-72 min-w-0 items-center gap-3">
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
@@ -46,21 +57,21 @@
<img
src={`/api/albums/${current.album_id}/cover`}
alt=""
class="h-12 w-12 rounded object-cover"
class="h-20 w-20 rounded-md object-cover"
onerror={onCoverError}
/>
</a>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{current.title}</div>
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
<a
href={`/artists/${current.artist_id}`}
class="truncate text-xs text-text-secondary hover:text-text-primary"
class="block truncate text-xs text-text-secondary hover:text-text-primary"
>
{current.artist_name}
</a>
</div>
<LikeButton entityType="track" entityId={current.id} />
<TrackMenu track={current} />
<TrackMenu track={current} direction="up" />
</div>
<!-- Center: seek row + transport row -->
@@ -69,7 +80,7 @@
<span>{player.error ?? 'Playback failed.'}</span>
<button
type="button"
class="rounded bg-accent px-3 py-1 text-background"
class="rounded bg-accent px-3 py-1.5 text-sm text-text-primary"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
@@ -77,6 +88,47 @@
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<!-- Transport row: prev / play / next, play is the accent focal point -->
<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
data-testid="play-spinner"
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 -->
<div class="flex items-center gap-3">
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(player.position)}
@@ -95,56 +147,39 @@
{formatDuration(player.duration)}
</span>
</div>
<div class="flex items-center justify-center gap-4">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="rounded p-1 disabled:opacity-40"
></button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="rounded px-3 py-1 bg-surface-hover"
>
{#if player.state === 'loading' || player.state === 'idle'}
<span data-testid="play-spinner" class="inline-block animate-spin"></span>
{:else if player.isPlaying}
{:else}
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="rounded p-1 disabled:opacity-40"
></button>
</div>
</div>
{/if}
<!-- Right: shuffle + repeat + volume -->
<div class="flex w-56 items-center justify-end gap-3">
<div class="flex w-48 items-center justify-end gap-2">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="rounded p-1 {player.shuffle ? 'text-accent' : 'text-text-secondary'}"
>🔀</button>
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="rounded p-1 {player.repeat !== 'off' ? 'text-accent' : 'text-text-secondary'}"
>🔁{player.repeat === 'one' ? '¹' : ''}</button>
<label class="flex items-center gap-2">
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>
<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"
@@ -153,7 +188,7 @@
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="w-28 accent-accent"
class="w-20 accent-accent"
/>
</label>
</div>
+10 -2
View File
@@ -3,7 +3,15 @@
import FlagPopover from './FlagPopover.svelte';
import type { TrackRef } from '$lib/api/types';
let { track }: { track: TrackRef } = $props();
let {
track,
direction = 'down'
}: {
track: TrackRef;
/** Whether the dropdown opens above the trigger ('up') or below ('down').
Pass 'up' when used near the bottom of the viewport (e.g. PlayerBar). */
direction?: 'up' | 'down';
} = $props();
let menuOpen = $state(false);
let popoverOpen = $state(false);
@@ -44,7 +52,7 @@
{#if menuOpen}
<div
role="menu"
class="absolute right-0 z-20 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-lg"
class="absolute right-0 z-20 w-48 rounded-md border border-border bg-surface p-1 shadow-lg {direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'}"
onclick={(e) => e.stopPropagation()}
>
<button