Design Prot Merge #1
@@ -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>
|
||||||
@@ -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<PlayerTrack[]>([]);
|
||||||
|
index = $state(-1);
|
||||||
|
isPlaying = $state(false);
|
||||||
|
loading = $state(false);
|
||||||
|
currentTime = $state(0);
|
||||||
|
duration = $state(0);
|
||||||
|
volume = $state(1);
|
||||||
|
muted = $state(false);
|
||||||
|
error = $state<string | null>(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();
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import Navbar from '$lib/components/Navbar.svelte';
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
import Footer from '$lib/components/Footer.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();
|
let { children, data } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,4 +15,6 @@
|
|||||||
<Navbar user={data.user} />
|
<Navbar user={data.user} />
|
||||||
<main class="flex-1">{@render children()}</main>
|
<main class="flex-1">{@render children()}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<div class:pb-20={player.current}></div>
|
||||||
|
<PlayerBar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user