feat(frontend): rework audio player and shared UI components
Expand the player store, split player controls (transport, seek, volume, track info), add Cover/PlaylistArt/NowPlaying UI primitives and a cover image proxy route.
This commit is contained in:
@@ -1,26 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
</script>
|
||||
|
||||
{#if player.current}
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-neutral-950/95 backdrop-blur">
|
||||
<div class="mx-auto grid max-w-6xl grid-cols-[1fr_auto] items-center gap-4 px-4 py-3 sm:grid-cols-3 sm:px-6">
|
||||
<TrackInfo />
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<div class="hidden w-full max-w-md sm:block">
|
||||
<SeekBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden justify-end sm:flex">
|
||||
<VolumeControl />
|
||||
</div>
|
||||
<div
|
||||
class="animate-enter fixed bottom-0 left-[264px] right-0 z-50 h-[92px] border-t border-[var(--mf-border)] bg-[var(--mf-bg)]"
|
||||
>
|
||||
<div
|
||||
class="grid h-full items-center gap-5 px-8"
|
||||
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
|
||||
>
|
||||
<TrackInfo />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<VolumeControl />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/theme/color';
|
||||
|
||||
function format(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = Math.floor(seconds % 60);
|
||||
return `${minutes}:${rest.toString().padStart(2, '0')}`;
|
||||
function seek(event: MouseEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
player.seekFraction((event.clientX - rect.left) / rect.width);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<span class="w-10 text-right text-xs tabular-nums text-neutral-500">{format(player.currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={player.duration || 0}
|
||||
step="0.1"
|
||||
value={player.currentTime}
|
||||
disabled={!player.duration}
|
||||
oninput={(event) => player.seek(Number(event.currentTarget.value))}
|
||||
aria-label="Progreso"
|
||||
class="h-1 w-full cursor-pointer accent-emerald-500"
|
||||
/>
|
||||
<span class="w-10 text-xs tabular-nums text-neutral-500">{format(player.duration)}</span>
|
||||
<div class="flex w-full max-w-[900px] items-center gap-[9px]">
|
||||
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]">
|
||||
{fmtTime(player.progress)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={seek}
|
||||
aria-label="Barra de progreso"
|
||||
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-[var(--mf-track)] transition-[height] hover:h-2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-[var(--mf-accent)] transition-[width] duration-75 ease-linear group-hover:brightness-110"
|
||||
style="width:{player.progressPercent}%"
|
||||
></div>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-[left,opacity] duration-75 ease-linear group-hover:opacity-100"
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
</button>
|
||||
<span class="w-8 text-[11px] tabular-nums text-[var(--mf-text-4)]">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="grid h-12 w-12 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-emerald-500/25 to-emerald-500/5 text-xl"
|
||||
>
|
||||
🎵
|
||||
</div>
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
hue={player.current.hue}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
|
||||
style="background:{player.artGradient}"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold">{player.current?.title}</p>
|
||||
{#if player.error}
|
||||
<p class="truncate text-xs text-red-400">{player.error}</p>
|
||||
{:else}
|
||||
<p class="truncate text-xs text-neutral-500">Musify</p>
|
||||
{/if}
|
||||
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]">
|
||||
{player.current.title}
|
||||
</div>
|
||||
<div class="truncate text-xs text-[var(--mf-text-3)]">{player.current.artist || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,39 +2,33 @@
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.previous()}
|
||||
disabled={!player.current}
|
||||
aria-label="Anterior"
|
||||
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white disabled:opacity-30"
|
||||
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M7 6h2v12H7z" />
|
||||
<path d="M20 6v12l-9-6z" />
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="2.5" height="14" rx="1" />
|
||||
<path d="M15 0 5 7l10 7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
disabled={!player.current}
|
||||
aria-label={player.isPlaying ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-11 w-11 place-items-center rounded-full bg-emerald-500 text-neutral-950 transition hover:bg-emerald-400 disabled:opacity-40"
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95"
|
||||
>
|
||||
{#if player.loading}
|
||||
<svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if player.isPlaying}
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||
{#if player.playing}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
|
||||
<rect x="7" y="0" width="3.5" height="12" rx="1" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M2.5 0 12 6l-9.5 6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -42,13 +36,12 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.next()}
|
||||
disabled={!player.hasNext}
|
||||
aria-label="Siguiente"
|
||||
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white disabled:opacity-30"
|
||||
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M15 6h2v12h-2z" />
|
||||
<path d="M4 6v12l9-6z" />
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<path d="M1 0 11 7 1 14z" />
|
||||
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,33 +2,44 @@
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleMute()}
|
||||
aria-label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
||||
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white"
|
||||
>
|
||||
{#if player.muted || player.volume === 0}
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M4 9v6h4l5 5V4L8 9H4z" />
|
||||
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||
<path d="M4 9v6h4l5 5V4L8 9H4z" />
|
||||
<path d="M16 8a5 5 0 0 1 0 8" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center justify-end gap-3.5">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={player.muted ? 0 : player.volume}
|
||||
max="100"
|
||||
value={player.volume}
|
||||
oninput={(event) => player.setVolume(Number(event.currentTarget.value))}
|
||||
aria-label="Volumen"
|
||||
class="h-1 w-24 cursor-pointer accent-emerald-500"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mf-volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { gradientForHue } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
hue: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { trackId, hue, size = 'medium', alt = '', class: klass = '', children }: Props = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
let loaded = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden {klass}"
|
||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
||||
>
|
||||
{#if !failed}
|
||||
<img
|
||||
src="/api/tracks/{trackId}/cover?size={size}"
|
||||
{alt}
|
||||
loading="lazy"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (failed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
let { paused = false }: Props = $props();
|
||||
|
||||
const bars = [
|
||||
{ delay: 0, dur: 0.9 },
|
||||
{ delay: 0.25, dur: 0.7 },
|
||||
{ delay: 0.1, dur: 1.05 },
|
||||
{ delay: 0.4, dur: 0.8 }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-white"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import { gradientForHue, HUES } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
trackIds: (string | number)[];
|
||||
hue: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { trackIds, hue, class: klass = '' }: Props = $props();
|
||||
|
||||
function hueFor(id: string | number) {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return HUES[hash % HUES.length];
|
||||
}
|
||||
|
||||
const mosaic = $derived(trackIds.length >= 4);
|
||||
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
|
||||
</script>
|
||||
|
||||
{#if mosaic}
|
||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||
{#each trackIds.slice(0, 4) as id (id)}
|
||||
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if single !== null}
|
||||
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
|
||||
{:else}
|
||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user