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