diff --git a/frontend/src/lib/components/player/PlayerBar.svelte b/frontend/src/lib/components/player/PlayerBar.svelte
new file mode 100644
index 0000000..2103422
--- /dev/null
+++ b/frontend/src/lib/components/player/PlayerBar.svelte
@@ -0,0 +1,26 @@
+
+
+{#if player.current}
+
+{/if}
diff --git a/frontend/src/lib/components/player/SeekBar.svelte b/frontend/src/lib/components/player/SeekBar.svelte
new file mode 100644
index 0000000..a3f9509
--- /dev/null
+++ b/frontend/src/lib/components/player/SeekBar.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {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)}
+
diff --git a/frontend/src/lib/components/player/TrackInfo.svelte b/frontend/src/lib/components/player/TrackInfo.svelte
new file mode 100644
index 0000000..ffbd02c
--- /dev/null
+++ b/frontend/src/lib/components/player/TrackInfo.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+ 🎵
+
+
+
{player.current?.title}
+ {#if player.error}
+
{player.error}
+ {:else}
+
Musify
+ {/if}
+
+
diff --git a/frontend/src/lib/components/player/TransportControls.svelte b/frontend/src/lib/components/player/TransportControls.svelte
new file mode 100644
index 0000000..f1ca4ed
--- /dev/null
+++ b/frontend/src/lib/components/player/TransportControls.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/player/VolumeControl.svelte b/frontend/src/lib/components/player/VolumeControl.svelte
new file mode 100644
index 0000000..5947c0a
--- /dev/null
+++ b/frontend/src/lib/components/player/VolumeControl.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+
player.setVolume(Number(event.currentTarget.value))}
+ aria-label="Volumen"
+ class="h-1 w-24 cursor-pointer accent-emerald-500"
+ />
+
diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts
new file mode 100644
index 0000000..7286a6b
--- /dev/null
+++ b/frontend/src/lib/player/player.svelte.ts
@@ -0,0 +1,125 @@
+import { browser } from '$app/environment';
+
+export interface PlayerTrack {
+ id: string;
+ title: string;
+}
+
+interface StreamResponse {
+ manifestUrl: string;
+ ticket: string;
+ expiresInSeconds: number;
+}
+
+class PlayerState {
+ queue = $state([]);
+ index = $state(-1);
+ isPlaying = $state(false);
+ loading = $state(false);
+ currentTime = $state(0);
+ duration = $state(0);
+ volume = $state(1);
+ muted = $state(false);
+ error = $state(null);
+
+ current = $derived(this.index >= 0 ? (this.queue[this.index] ?? null) : null);
+ hasNext = $derived(this.index < this.queue.length - 1);
+ hasPrevious = $derived(this.index > 0);
+
+ #audio: HTMLAudioElement | null = null;
+
+ #ensureAudio(): HTMLAudioElement {
+ 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.addEventListener('ended', () => this.next());
+ audio.addEventListener('error', () => {
+ this.loading = 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);
+ }
+
+ async #load(index: number) {
+ if (!browser || index < 0 || index >= this.queue.length) return;
+
+ this.index = index;
+ this.error = null;
+ this.loading = true;
+ this.currentTime = 0;
+ this.duration = 0;
+
+ const audio = this.#ensureAudio();
+ 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)}`;
+ await audio.play();
+ } catch {
+ this.loading = false;
+ this.error = 'No se pudo reproducir la pista.';
+ }
+ }
+
+ toggle() {
+ const audio = this.#audio;
+ if (!audio || !this.current) return;
+ if (audio.paused) audio.play();
+ else audio.pause();
+ }
+
+ next() {
+ if (this.hasNext) this.#load(this.index + 1);
+ else if (this.#audio) this.#audio.pause();
+ }
+
+ previous() {
+ if (this.currentTime > 3 || !this.hasPrevious) this.seek(0);
+ else this.#load(this.index - 1);
+ }
+
+ seek(seconds: number) {
+ const audio = this.#audio;
+ if (!audio) return;
+ audio.currentTime = seconds;
+ this.currentTime = seconds;
+ }
+
+ 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;
+ }
+}
+
+export const player = new PlayerState();
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index da082b8..b8d7216 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -3,6 +3,8 @@
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
+ import PlayerBar from '$lib/components/player/PlayerBar.svelte';
+ import { player } from '$lib/player/player.svelte';
let { children, data } = $props();
@@ -13,4 +15,6 @@
{@render children()}
+
+
diff --git a/frontend/src/routes/api/tracks/[id]/stream/+server.ts b/frontend/src/routes/api/tracks/[id]/stream/+server.ts
new file mode 100644
index 0000000..68618cc
--- /dev/null
+++ b/frontend/src/routes/api/tracks/[id]/stream/+server.ts
@@ -0,0 +1,20 @@
+import type { RequestHandler } from './$types';
+import { json, error } from '@sveltejs/kit';
+import { createApiClient } from '$lib/server/api';
+
+export const GET: RequestHandler = async ({ params, locals, fetch }) => {
+ if (!locals.accessToken) {
+ error(401, 'Inicia sesión para reproducir.');
+ }
+
+ const api = createApiClient({ fetch, accessToken: locals.accessToken });
+ const { data, error: err, response } = await api.GET('/tracks/{id}/stream', {
+ params: { path: { id: params.id } }
+ });
+
+ if (err || !data) {
+ error(response?.status ?? 502, 'No se pudo obtener el stream de la pista.');
+ }
+
+ return json(data);
+};