From 1f531ea70ec27eed37448194710e7f8d031c584a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:54:51 +0200 Subject: [PATCH] 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. --- .../lib/components/player/PlayerBar.svelte | 30 +- .../src/lib/components/player/SeekBar.svelte | 45 +-- .../lib/components/player/TrackInfo.svelte | 30 +- .../player/TransportControls.svelte | 41 ++- .../components/player/VolumeControl.svelte | 57 ++-- frontend/src/lib/components/ui/Cover.svelte | 35 +++ .../src/lib/components/ui/NowPlaying.svelte | 25 ++ .../src/lib/components/ui/PlaylistArt.svelte | 34 +++ frontend/src/lib/player/player.svelte.ts | 274 +++++++++++++----- .../routes/api/tracks/[id]/cover/+server.ts | 25 ++ 10 files changed, 430 insertions(+), 166 deletions(-) create mode 100644 frontend/src/lib/components/ui/Cover.svelte create mode 100644 frontend/src/lib/components/ui/NowPlaying.svelte create mode 100644 frontend/src/lib/components/ui/PlaylistArt.svelte create mode 100644 frontend/src/routes/api/tracks/[id]/cover/+server.ts diff --git a/frontend/src/lib/components/player/PlayerBar.svelte b/frontend/src/lib/components/player/PlayerBar.svelte index 2103422..c5f185b 100644 --- a/frontend/src/lib/components/player/PlayerBar.svelte +++ b/frontend/src/lib/components/player/PlayerBar.svelte @@ -1,26 +1,22 @@ -{#if player.current} -
-
- - -
- - -
- - +
+
+ +
+ +
+
-{/if} +
diff --git a/frontend/src/lib/components/player/SeekBar.svelte b/frontend/src/lib/components/player/SeekBar.svelte index a3f9509..deebf69 100644 --- a/frontend/src/lib/components/player/SeekBar.svelte +++ b/frontend/src/lib/components/player/SeekBar.svelte @@ -1,26 +1,33 @@ -
- {format(player.currentTime)} - player.seek(Number(event.currentTarget.value))} - aria-label="Progreso" - class="h-1 w-full cursor-pointer accent-emerald-500" - /> - {format(player.duration)} +
+ + {fmtTime(player.progress)} + + + + {fmtTime(player.current.duration)} +
diff --git a/frontend/src/lib/components/player/TrackInfo.svelte b/frontend/src/lib/components/player/TrackInfo.svelte index ffbd02c..3140637 100644 --- a/frontend/src/lib/components/player/TrackInfo.svelte +++ b/frontend/src/lib/components/player/TrackInfo.svelte @@ -1,19 +1,27 @@
-
- 🎵 -
+ {#if player.current.id} + + {:else} +
+ {/if}
-

{player.current?.title}

- {#if player.error} -

{player.error}

- {:else} -

Musify

- {/if} +
+ {player.current.title} +
+
{player.current.artist || '—'}
diff --git a/frontend/src/lib/components/player/TransportControls.svelte b/frontend/src/lib/components/player/TransportControls.svelte index f1ca4ed..d53fd7b 100644 --- a/frontend/src/lib/components/player/TransportControls.svelte +++ b/frontend/src/lib/components/player/TransportControls.svelte @@ -2,39 +2,33 @@ import { player } from '$lib/player/player.svelte'; -
+
@@ -42,13 +36,12 @@
diff --git a/frontend/src/lib/components/player/VolumeControl.svelte b/frontend/src/lib/components/player/VolumeControl.svelte index 5947c0a..ea2b3e7 100644 --- a/frontend/src/lib/components/player/VolumeControl.svelte +++ b/frontend/src/lib/components/player/VolumeControl.svelte @@ -2,33 +2,44 @@ import { player } from '$lib/player/player.svelte'; -
- +
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}%)" />
+ + diff --git a/frontend/src/lib/components/ui/Cover.svelte b/frontend/src/lib/components/ui/Cover.svelte new file mode 100644 index 0000000..0601e1b --- /dev/null +++ b/frontend/src/lib/components/ui/Cover.svelte @@ -0,0 +1,35 @@ + + +
+ {#if !failed} + (loaded = true)} + onerror={() => (failed = true)} + class="h-full w-full object-cover" + /> + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/NowPlaying.svelte b/frontend/src/lib/components/ui/NowPlaying.svelte new file mode 100644 index 0000000..82a9d4b --- /dev/null +++ b/frontend/src/lib/components/ui/NowPlaying.svelte @@ -0,0 +1,25 @@ + + +
+ {#each bars as bar, i (i)} + + {/each} +
diff --git a/frontend/src/lib/components/ui/PlaylistArt.svelte b/frontend/src/lib/components/ui/PlaylistArt.svelte new file mode 100644 index 0000000..14535a4 --- /dev/null +++ b/frontend/src/lib/components/ui/PlaylistArt.svelte @@ -0,0 +1,34 @@ + + +{#if mosaic} +
+ {#each trackIds.slice(0, 4) as id (id)} + + {/each} +
+{:else if single !== null} + +{:else} +
+{/if} diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts index 7286a6b..0601fe7 100644 --- a/frontend/src/lib/player/player.svelte.ts +++ b/frontend/src/lib/player/player.svelte.ts @@ -1,124 +1,254 @@ import { browser } from '$app/environment'; +import { HUES, accentForHue, gradientForHue } from '$lib/theme/color'; +import { extractAccent, type Accent } from '$lib/theme/palette'; export interface PlayerTrack { - id: string; + id: string | number; title: string; + artist: string; + duration: number; + hue: number; } -interface StreamResponse { - manifestUrl: string; - ticket: string; - expiresInSeconds: number; +export interface Playlist { + id: string | number; + name: string; + trackIds: (string | number)[]; +} + +export interface QueueItem { + id: string | number; + title: string; + artist?: string; +} + +const RECENT_LIMIT = 12; + +const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0] }; + +function hueFor(id: string | number): 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]; +} + +function toTrack(item: QueueItem): PlayerTrack { + return { + id: item.id, + title: item.title, + artist: item.artist ?? '', + duration: 0, + hue: hueFor(item.id) + }; } class PlayerState { - queue = $state([]); - index = $state(-1); - isPlaying = $state(false); + tracks = $state([]); + currentId = $state(null); + playing = $state(false); + progress = $state(0); + volume = $state(70); loading = $state(false); - currentTime = $state(0); - duration = $state(0); - volume = $state(1); - muted = $state(false); - error = $state(null); + error = $state(''); - current = $derived(this.index >= 0 ? (this.queue[this.index] ?? null) : null); - hasNext = $derived(this.index < this.queue.length - 1); - hasPrevious = $derived(this.index > 0); + recentlyPlayed = $state([]); + playlists = $state([]); + + accentColor = $state(null); + gradientColor = $state(null); #audio: HTMLAudioElement | null = null; + #loadToken = 0; + #accentCache = new Map(); - #ensureAudio(): HTMLAudioElement { + current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY); + accent = $derived(this.accentColor ?? accentForHue(this.current.hue)); + artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue)); + progressPercent = $derived( + this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0 + ); + + get isPlaying() { + return this.playing; + } + + #audioEl(): HTMLAudioElement | null { + if (!browser) return null; if (this.#audio) return this.#audio; - const audio = new Audio(); - audio.preload = 'metadata'; - audio.volume = this.volume; - - audio.addEventListener('timeupdate', () => (this.currentTime = audio.currentTime)); - audio.addEventListener( - 'durationchange', - () => (this.duration = Number.isFinite(audio.duration) ? audio.duration : 0) - ); - audio.addEventListener('play', () => (this.isPlaying = true)); - audio.addEventListener('pause', () => (this.isPlaying = false)); - audio.addEventListener('waiting', () => (this.loading = true)); - audio.addEventListener('playing', () => (this.loading = false)); - audio.addEventListener('canplay', () => (this.loading = false)); + audio.preload = 'auto'; + audio.volume = this.volume / 100; + audio.addEventListener('timeupdate', () => { + this.progress = audio.currentTime; + }); + audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); + audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration)); + audio.addEventListener('play', () => (this.playing = true)); + audio.addEventListener('pause', () => (this.playing = false)); audio.addEventListener('ended', () => this.next()); audio.addEventListener('error', () => { this.loading = false; + this.playing = false; this.error = 'No se pudo reproducir la pista.'; }); - this.#audio = audio; return audio; } - async playQueue(tracks: PlayerTrack[], startIndex = 0) { - this.queue = tracks; - await this.#load(startIndex); + #syncDuration(value: number) { + if (!Number.isFinite(value) || value <= 0) return; + const id = this.currentId; + this.tracks = this.tracks.map((t) => + t.id === id ? { ...t, duration: Math.round(value) } : t + ); } - async #load(index: number) { - if (!browser || index < 0 || index >= this.queue.length) return; + #index() { + return this.tracks.findIndex((t) => t.id === this.currentId); + } - this.index = index; - this.error = null; + #pushRecent(track: PlayerTrack) { + if (!track.id) return; + this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice( + 0, + RECENT_LIMIT + ); + } + + async #applyAccent(id: string | number) { + const key = String(id); + const cached = this.#accentCache.get(key); + if (cached) { + this.accentColor = cached.accent; + this.gradientColor = cached.gradient; + return; + } + const result = await extractAccent(`/api/tracks/${id}/cover?size=small`); + if (this.currentId !== id) return; + if (result) { + this.#accentCache.set(key, result); + this.accentColor = result.accent; + this.gradientColor = result.gradient; + } else { + this.accentColor = null; + this.gradientColor = null; + } + } + + async #loadCurrent() { + const audio = this.#audioEl(); + if (!audio || this.currentId === null) return; + const id = this.currentId; + this.#applyAccent(id); + const token = ++this.#loadToken; this.loading = true; - this.currentTime = 0; - this.duration = 0; - - const audio = this.#ensureAudio(); + this.error = ''; + this.progress = 0; try { - const response = await fetch(`/api/tracks/${this.queue[index].id}/stream`); - if (!response.ok) throw new Error(String(response.status)); - - const stream: StreamResponse = await response.json(); - audio.src = `${stream.manifestUrl}?t=${encodeURIComponent(stream.ticket)}`; + const res = await fetch(`/api/tracks/${id}/stream`); + if (!res.ok) throw new Error(String(res.status)); + const { manifestUrl, ticket } = (await res.json()) as { + manifestUrl: string; + ticket: string; + }; + if (token !== this.#loadToken) return; + const sep = manifestUrl.includes('?') ? '&' : '?'; + audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl; + audio.volume = this.volume / 100; await audio.play(); - } catch { this.loading = false; + this.#pushRecent(this.current); + } catch { + if (token !== this.#loadToken) return; + this.loading = false; + this.playing = false; this.error = 'No se pudo reproducir la pista.'; } } + playlistTracks(ids: (string | number)[]): PlayerTrack[] { + return ids + .map((id) => this.tracks.find((t) => t.id === id)) + .filter((t): t is PlayerTrack => Boolean(t)); + } + + mosaicFor(ids: (string | number)[]): string[] { + const gradients = this.playlistTracks(ids) + .slice(0, 4) + .map((t) => gradientForHue(t.hue)); + while (gradients.length < 4) gradients.push('var(--mf-surface)'); + return gradients; + } + + playQueue(list: QueueItem[], startIndex = 0) { + if (list.length === 0) return; + this.tracks = list.map(toTrack); + const start = this.tracks[startIndex] ?? this.tracks[0]; + this.currentId = start.id; + this.#loadCurrent(); + } + + playTrack(id: string | number) { + if (this.currentId === id) { + this.toggle(); + return; + } + const known = + this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id); + if (known && !this.tracks.some((t) => t.id === id)) { + this.tracks = [known]; + } + if (!this.tracks.some((t) => t.id === id)) return; + this.currentId = id; + this.#loadCurrent(); + } + + playPlaylist(id: string | number) { + const playlist = this.playlists.find((p) => p.id === id); + if (!playlist) return; + const items = this.playlistTracks(playlist.trackIds); + if (items.length > 0) this.playQueue(items, 0); + } + toggle() { - const audio = this.#audio; - if (!audio || !this.current) return; - if (audio.paused) audio.play(); + const audio = this.#audioEl(); + if (!audio || this.currentId === null) return; + if (audio.paused) audio.play().catch(() => {}); else audio.pause(); } next() { - if (this.hasNext) this.#load(this.index + 1); - else if (this.#audio) this.#audio.pause(); + if (this.tracks.length === 0) return; + const idx = this.#index(); + this.currentId = this.tracks[(idx + 1) % this.tracks.length].id; + this.#loadCurrent(); } previous() { - if (this.currentTime > 3 || !this.hasPrevious) this.seek(0); - else this.#load(this.index - 1); + if (this.tracks.length === 0) return; + const audio = this.#audioEl(); + if (audio && audio.currentTime > 3) { + audio.currentTime = 0; + return; + } + const idx = this.#index(); + this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id; + this.#loadCurrent(); } - seek(seconds: number) { - const audio = this.#audio; - if (!audio) return; - audio.currentTime = seconds; - this.currentTime = seconds; + seekFraction(fraction: number) { + const audio = this.#audioEl(); + const clamped = Math.min(1, Math.max(0, fraction)); + const target = clamped * this.current.duration; + this.progress = target; + if (audio && Number.isFinite(audio.duration)) audio.currentTime = target; } setVolume(value: number) { - this.volume = value; - this.muted = value === 0; - if (this.#audio) { - this.#audio.volume = value; - this.#audio.muted = this.muted; - } - } - - toggleMute() { - this.muted = !this.muted; - if (this.#audio) this.#audio.muted = this.muted; + this.volume = Math.min(100, Math.max(0, Math.round(value))); + const audio = this.#audioEl(); + if (audio) audio.volume = this.volume / 100; } } diff --git a/frontend/src/routes/api/tracks/[id]/cover/+server.ts b/frontend/src/routes/api/tracks/[id]/cover/+server.ts new file mode 100644 index 0000000..8e12c44 --- /dev/null +++ b/frontend/src/routes/api/tracks/[id]/cover/+server.ts @@ -0,0 +1,25 @@ +import type { RequestHandler } from './$types'; +import { apiConfig } from '$lib/server/config'; + +const ALLOWED = new Set(['small', 'medium', 'large']); + +export const GET: RequestHandler = async ({ params, url, fetch }) => { + const size = url.searchParams.get('size') ?? 'medium'; + const safeSize = ALLOWED.has(size) ? size : 'medium'; + + const upstream = await fetch( + `${apiConfig.baseUrl}/tracks/${params.id}/cover?size=${safeSize}` + ); + + if (!upstream.ok) { + return new Response(null, { status: upstream.status }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', + 'Cache-Control': 'public, max-age=86400' + } + }); +};