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.
senzapaura_es/src/lib/player/player.svelte.ts

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();

Powered by TurnKey Linux.