feat(frontend): music player component with separated controls and stream proxy

This commit is contained in:
2026-07-06 02:40:44 +02:00
parent e34347e450
commit c04d79beb0
8 changed files with 308 additions and 0 deletions
@@ -0,0 +1,26 @@
<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>
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
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')}`;
}
</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>
@@ -0,0 +1,19 @@
<script lang="ts">
import { player } from '$lib/player/player.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>
<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>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
</script>
<div class="flex items-center gap-2">
<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"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M7 6h2v12H7z" />
<path d="M20 6v12l-9-6z" />
</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"
>
{#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" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<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"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M15 6h2v12h-2z" />
<path d="M4 6v12l9-6z" />
</svg>
</button>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
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>
<input
type="range"
min="0"
max="1"
step="0.01"
value={player.muted ? 0 : player.volume}
oninput={(event) => player.setVolume(Number(event.currentTarget.value))}
aria-label="Volumen"
class="h-1 w-24 cursor-pointer accent-emerald-500"
/>
</div>