From 9b34f2db84dca7e095b1a246518c1dcab7481b22 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:40:44 +0200 Subject: [PATCH] feat(frontend): music player component with separated controls and stream proxy --- .../lib/components/player/PlayerBar.svelte | 26 ++++ .../src/lib/components/player/SeekBar.svelte | 26 ++++ .../lib/components/player/TrackInfo.svelte | 19 +++ .../player/TransportControls.svelte | 54 ++++++++ .../components/player/VolumeControl.svelte | 34 +++++ frontend/src/lib/player/player.svelte.ts | 125 ++++++++++++++++++ frontend/src/routes/+layout.svelte | 4 + .../routes/api/tracks/[id]/stream/+server.ts | 20 +++ 8 files changed, 308 insertions(+) create mode 100644 frontend/src/lib/components/player/PlayerBar.svelte create mode 100644 frontend/src/lib/components/player/SeekBar.svelte create mode 100644 frontend/src/lib/components/player/TrackInfo.svelte create mode 100644 frontend/src/lib/components/player/TransportControls.svelte create mode 100644 frontend/src/lib/components/player/VolumeControl.svelte create mode 100644 frontend/src/lib/player/player.svelte.ts create mode 100644 frontend/src/routes/api/tracks/[id]/stream/+server.ts 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()}