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'
+ }
+ });
+};