You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
397 lines
11 KiB
397 lines
11 KiB
/**
|
|
* Reproductor global.
|
|
*
|
|
* Es una unica instancia a nivel de modulo, no un contexto de Svelte: la barra
|
|
* de reproduccion vive en el layout raiz y tiene que seguir sonando mientras se
|
|
* navega entre paginas, asi que su estado no puede estar atado al ciclo de vida
|
|
* de ningun componente.
|
|
*
|
|
* El elemento <audio> se crea de forma perezosa y solo en el navegador; en el
|
|
* servidor el estado existe igual, simplemente no suena nada.
|
|
*/
|
|
|
|
import { browser } from '$app/environment';
|
|
import {
|
|
alTerminarPista,
|
|
anteriorPosicion,
|
|
ordenAleatorio,
|
|
ordenNatural,
|
|
siguienteModoRepeticion,
|
|
siguientePosicion,
|
|
type ModoRepeticion
|
|
} from './cola';
|
|
|
|
/** Un tema tal y como lo necesita el reproductor, ya desnormalizado. */
|
|
export interface PistaReproductor {
|
|
/**
|
|
* Identifica la pista dentro de la cola. Para la version principal de un
|
|
* tema es su slug; para una alternativa musical, "slug#version".
|
|
*/
|
|
id: string;
|
|
/** Slug del tema al que pertenece, para enlazar a su ficha. */
|
|
cancionSlug: string;
|
|
titulo: string;
|
|
/** URL publica del fragmento de muestra. */
|
|
audio: string;
|
|
albumTitulo: string;
|
|
albumSlug: string;
|
|
portada: string;
|
|
estiloNombre: string;
|
|
estiloSlug: string;
|
|
/** Duracion declarada en el contenido, en segundos. */
|
|
duracion?: number;
|
|
}
|
|
|
|
const CLAVE_VOLUMEN = 'senzapaura:volumen';
|
|
|
|
/** Al pulsar "anterior" dentro de estos segundos se salta al tema previo. */
|
|
const UMBRAL_REINICIO = 3;
|
|
|
|
function leerVolumenGuardado(): number {
|
|
if (!browser) return 0.8;
|
|
try {
|
|
const guardado = localStorage.getItem(CLAVE_VOLUMEN);
|
|
const valor = guardado === null ? NaN : Number(guardado);
|
|
return Number.isFinite(valor) && valor >= 0 && valor <= 1 ? valor : 0.8;
|
|
} catch {
|
|
// Navegacion privada o almacenamiento bloqueado: el volumen por defecto
|
|
// sirve igual, no merece romper el reproductor.
|
|
return 0.8;
|
|
}
|
|
}
|
|
|
|
class Reproductor {
|
|
/** Cola en orden natural (el del album). */
|
|
cola = $state<PistaReproductor[]>([]);
|
|
/** Indices de `cola` en el orden en que van a sonar. */
|
|
orden = $state<number[]>([]);
|
|
/** Posicion dentro de `orden`. -1 significa que no hay nada cargado. */
|
|
posicion = $state(-1);
|
|
|
|
sonando = $state(false);
|
|
cargando = $state(false);
|
|
/** Segundo actual de reproduccion. */
|
|
tiempo = $state(0);
|
|
/** Duracion real del audio cargado; 0 mientras no se conoce. */
|
|
duracionAudio = $state(0);
|
|
volumen = $state(leerVolumenGuardado());
|
|
silenciado = $state(false);
|
|
aleatorio = $state(false);
|
|
repetir = $state<ModoRepeticion>('no');
|
|
/** Mensaje de error de reproduccion, si lo hay. */
|
|
error = $state<string | null>(null);
|
|
/** Panel lateral con la cola abierto. */
|
|
colaVisible = $state(false);
|
|
|
|
/** Pista sonando ahora mismo, o `null`. */
|
|
pista = $derived.by(() => {
|
|
const indice = this.orden[this.posicion];
|
|
return indice === undefined ? null : (this.cola[indice] ?? null);
|
|
});
|
|
|
|
/** Duracion a mostrar: la real si ya se conoce, si no la del contenido. */
|
|
duracion = $derived(this.duracionAudio || (this.pista?.duracion ?? 0));
|
|
|
|
/** Progreso entre 0 y 1. */
|
|
progreso = $derived(this.duracion > 0 ? Math.min(1, this.tiempo / this.duracion) : 0);
|
|
|
|
/** Pistas en el orden en que van a sonar, para el panel de cola. */
|
|
colaOrdenada = $derived(this.orden.map((i) => this.cola[i]).filter(Boolean));
|
|
|
|
hayAnterior = $derived(this.posicion > 0 || this.repetir === 'todo');
|
|
haySiguiente = $derived(this.posicion < this.orden.length - 1 || this.repetir === 'todo');
|
|
|
|
/** No es reactivo a proposito: es un recurso, no estado de interfaz. */
|
|
private audio: HTMLAudioElement | null = null;
|
|
|
|
/* --------------------------------------------------------------------
|
|
* Elemento de audio
|
|
* ----------------------------------------------------------------- */
|
|
|
|
private asegurarAudio(): HTMLAudioElement | null {
|
|
if (!browser) return null;
|
|
if (this.audio) return this.audio;
|
|
|
|
const el = new Audio();
|
|
el.preload = 'metadata';
|
|
el.volume = this.volumen;
|
|
el.muted = this.silenciado;
|
|
|
|
el.addEventListener('timeupdate', () => {
|
|
this.tiempo = el.currentTime;
|
|
});
|
|
el.addEventListener('durationchange', () => {
|
|
this.duracionAudio = Number.isFinite(el.duration) ? el.duration : 0;
|
|
});
|
|
el.addEventListener('play', () => {
|
|
this.sonando = true;
|
|
});
|
|
el.addEventListener('pause', () => {
|
|
this.sonando = false;
|
|
});
|
|
el.addEventListener('waiting', () => {
|
|
this.cargando = true;
|
|
});
|
|
el.addEventListener('playing', () => {
|
|
this.cargando = false;
|
|
this.error = null;
|
|
});
|
|
el.addEventListener('ended', () => this.alTerminar());
|
|
el.addEventListener('error', () => {
|
|
this.error = 'No se ha podido cargar este audio.';
|
|
this.sonando = false;
|
|
this.cargando = false;
|
|
});
|
|
|
|
this.audio = el;
|
|
return el;
|
|
}
|
|
|
|
/** Carga la pista de la posicion actual y, si se pide, la arranca. */
|
|
private cargar(reproducir: boolean): void {
|
|
const el = this.asegurarAudio();
|
|
const pista = this.pista;
|
|
if (!el || !pista) return;
|
|
|
|
this.tiempo = 0;
|
|
this.duracionAudio = 0;
|
|
this.error = null;
|
|
|
|
el.src = pista.audio;
|
|
el.load();
|
|
this.publicarEnSistema(pista);
|
|
|
|
if (reproducir) void this.arrancar();
|
|
}
|
|
|
|
private async arrancar(): Promise<void> {
|
|
const el = this.asegurarAudio();
|
|
if (!el) return;
|
|
try {
|
|
this.cargando = true;
|
|
await el.play();
|
|
} catch {
|
|
// Reproduccion bloqueada por el navegador (o interrumpida por otra
|
|
// llamada a load()): se refleja como "en pausa", sin ruido.
|
|
this.sonando = false;
|
|
} finally {
|
|
this.cargando = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publica los metadatos en la Media Session para que los controles del
|
|
* sistema operativo y los auriculares muestren y manejen el tema.
|
|
*/
|
|
private publicarEnSistema(pista: PistaReproductor): void {
|
|
if (!browser || !('mediaSession' in navigator)) return;
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: pista.titulo,
|
|
artist: pista.estiloNombre,
|
|
album: pista.albumTitulo,
|
|
artwork: [{ src: pista.portada, sizes: '512x512' }]
|
|
});
|
|
|
|
navigator.mediaSession.setActionHandler('play', () => void this.arrancar());
|
|
navigator.mediaSession.setActionHandler('pause', () => this.pausar());
|
|
navigator.mediaSession.setActionHandler('previoustrack', () => this.anterior());
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => this.siguiente());
|
|
}
|
|
|
|
private alTerminar(): void {
|
|
const decision = alTerminarPista(this.posicion, this.orden.length, this.repetir);
|
|
|
|
if (decision.accion === 'repetir') {
|
|
this.buscar(0);
|
|
void this.arrancar();
|
|
return;
|
|
}
|
|
if (decision.accion === 'ir') {
|
|
this.posicion = decision.posicion;
|
|
this.cargar(true);
|
|
return;
|
|
}
|
|
|
|
this.sonando = false;
|
|
this.tiempo = 0;
|
|
}
|
|
|
|
/* --------------------------------------------------------------------
|
|
* API publica
|
|
* ----------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Sustituye la cola por `pistas` y empieza a sonar desde `indice`.
|
|
* Es lo que hace "Reproducir album" y el clic sobre un tema de un listado.
|
|
*/
|
|
reproducirLista(pistas: PistaReproductor[], indice = 0): void {
|
|
const reproducibles = pistas.filter((p) => p.audio);
|
|
if (reproducibles.length === 0) {
|
|
this.error = 'Todavía no hay audio disponible para estos temas.';
|
|
return;
|
|
}
|
|
|
|
// El indice llega referido a la lista original; se traduce a la lista ya
|
|
// filtrada para que no se descoloque si algun tema no tiene audio.
|
|
const pistaPedida = pistas[indice];
|
|
const inicio = Math.max(
|
|
0,
|
|
reproducibles.findIndex((p) => p.id === pistaPedida?.id)
|
|
);
|
|
|
|
this.cola = reproducibles;
|
|
this.orden = this.aleatorio
|
|
? ordenAleatorio(reproducibles.length, inicio)
|
|
: ordenNatural(reproducibles.length);
|
|
this.posicion = this.aleatorio ? 0 : inicio;
|
|
this.cargar(true);
|
|
}
|
|
|
|
/**
|
|
* Alterna una pista concreta: si ya es la actual, pausa o reanuda; si no,
|
|
* la pone a sonar dentro de su contexto (el album del que forma parte).
|
|
*/
|
|
alternarPista(pista: PistaReproductor, contexto: PistaReproductor[] = [pista]): void {
|
|
if (this.pista?.id === pista.id) {
|
|
this.alternar();
|
|
return;
|
|
}
|
|
const indice = Math.max(
|
|
0,
|
|
contexto.findIndex((p) => p.id === pista.id)
|
|
);
|
|
this.reproducirLista(contexto, indice);
|
|
}
|
|
|
|
/** Play/pausa del tema actual. */
|
|
alternar(): void {
|
|
if (!this.pista) return;
|
|
if (this.sonando) this.pausar();
|
|
else void this.arrancar();
|
|
}
|
|
|
|
pausar(): void {
|
|
this.audio?.pause();
|
|
this.sonando = false;
|
|
}
|
|
|
|
siguiente(): void {
|
|
const destino = siguientePosicion(this.posicion, this.orden.length, this.repetir === 'todo');
|
|
if (destino === null) return;
|
|
this.posicion = destino;
|
|
this.cargar(true);
|
|
}
|
|
|
|
/**
|
|
* Vuelve al principio del tema si ya ha avanzado un poco; si acaba de
|
|
* empezar, salta al tema anterior. Es el comportamiento habitual y evita
|
|
* saltos accidentales.
|
|
*/
|
|
anterior(): void {
|
|
if (this.tiempo > UMBRAL_REINICIO) {
|
|
this.buscar(0);
|
|
return;
|
|
}
|
|
const destino = anteriorPosicion(this.posicion, this.orden.length, this.repetir === 'todo');
|
|
if (destino === null) {
|
|
this.buscar(0);
|
|
return;
|
|
}
|
|
this.posicion = destino;
|
|
this.cargar(true);
|
|
}
|
|
|
|
/** Salta a una posicion concreta de la cola (panel lateral). */
|
|
irA(posicion: number): void {
|
|
if (posicion < 0 || posicion >= this.orden.length) return;
|
|
this.posicion = posicion;
|
|
this.cargar(true);
|
|
}
|
|
|
|
/** Coloca el cabezal en un segundo concreto. */
|
|
buscar(segundos: number): void {
|
|
const limite = this.duracion || 0;
|
|
const destino = Math.max(0, Math.min(segundos, limite));
|
|
this.tiempo = destino;
|
|
const el = this.asegurarAudio();
|
|
if (el) el.currentTime = destino;
|
|
}
|
|
|
|
/** Adelanta o retrocede `delta` segundos. */
|
|
desplazar(delta: number): void {
|
|
this.buscar(this.tiempo + delta);
|
|
}
|
|
|
|
ajustarVolumen(valor: number): void {
|
|
const v = Math.max(0, Math.min(1, valor));
|
|
this.volumen = v;
|
|
this.silenciado = v === 0;
|
|
|
|
const el = this.asegurarAudio();
|
|
if (el) {
|
|
el.volume = v;
|
|
el.muted = this.silenciado;
|
|
}
|
|
if (browser) {
|
|
try {
|
|
localStorage.setItem(CLAVE_VOLUMEN, String(v));
|
|
} catch {
|
|
// Sin almacenamiento, el volumen simplemente no se recuerda.
|
|
}
|
|
}
|
|
}
|
|
|
|
alternarSilencio(): void {
|
|
this.silenciado = !this.silenciado;
|
|
const el = this.asegurarAudio();
|
|
if (el) el.muted = this.silenciado;
|
|
}
|
|
|
|
alternarAleatorio(): void {
|
|
this.aleatorio = !this.aleatorio;
|
|
const indiceActual = this.orden[this.posicion] ?? 0;
|
|
|
|
if (this.aleatorio) {
|
|
this.orden = ordenAleatorio(this.cola.length, indiceActual);
|
|
this.posicion = 0;
|
|
} else {
|
|
this.orden = ordenNatural(this.cola.length);
|
|
this.posicion = indiceActual;
|
|
}
|
|
}
|
|
|
|
ciclarRepeticion(): void {
|
|
this.repetir = siguienteModoRepeticion(this.repetir);
|
|
}
|
|
|
|
alternarPanelCola(): void {
|
|
this.colaVisible = !this.colaVisible;
|
|
}
|
|
|
|
/** Vacia la cola y detiene la reproduccion. */
|
|
limpiar(): void {
|
|
this.pausar();
|
|
if (this.audio) this.audio.removeAttribute('src');
|
|
this.cola = [];
|
|
this.orden = [];
|
|
this.posicion = -1;
|
|
this.tiempo = 0;
|
|
this.duracionAudio = 0;
|
|
this.colaVisible = false;
|
|
}
|
|
|
|
/** `true` si esa cancion es la que esta cargada ahora mismo. */
|
|
esActual(id: string): boolean {
|
|
return this.pista?.id === id;
|
|
}
|
|
|
|
/** `true` si esa cancion es la actual y ademas esta sonando. */
|
|
estaSonando(id: string): boolean {
|
|
return this.esActual(id) && this.sonando;
|
|
}
|
|
}
|
|
|
|
/** Instancia unica del reproductor para toda la aplicacion. */
|
|
export const reproductor = new Reproductor();
|