Keep waveform out of compact player

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 7f1b0e8c75
commit 29e8789312

@ -68,23 +68,4 @@ test.describe('Reproductor', () => {
await cola.getByRole('button', { name: /El último tren/ }).click();
await expect(temaEnBarra(page, 'el-ultimo-tren')).toBeVisible();
});
test('dibuja la onda precalculada sin descargar otro audio', async ({ page }) => {
await page.goto('/albumes/cupido-sin-flechas');
const respuesta = page.waitForResponse((r) =>
new URL(r.url()).pathname.endsWith(
'/ondas/cupido-sin-flechas/01-amame-esta-noche-nada-mas.json'
)
);
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
const onda = page.getByRole('region', { name: 'Reproductor' }).locator('[data-forma-onda]');
await expect(onda).toBeVisible();
expect((await respuesta).status()).toBe(200);
// Un único trazo por estado (pendiente y escuchado), no cientos de nodos
// que hubiera que actualizar en cada cuadro de la reproducción.
await expect(onda.locator('path')).toHaveCount(2);
expect((await onda.locator('path').first().getAttribute('d'))?.match(/M/g)).toHaveLength(128);
});
});

@ -15,64 +15,18 @@
tiempo: number;
duracion: number;
onbuscar: (segundos: number) => void;
/** JSON de amplitudes precalculadas. Sin él queda el carril convencional. */
onda?: string;
/** Desactiva la barra cuando no hay nada cargado. */
activa?: boolean;
}
let { tiempo, duracion, onbuscar, onda, activa = true }: Props = $props();
let { tiempo, duracion, onbuscar, activa = true }: Props = $props();
let arrastrando = $state(false);
let valorLocal = $state(0);
let picos = $state<number[]>([]);
const maximo = $derived(duracion > 0 ? duracion : 0);
const mostrado = $derived(arrastrando ? valorLocal : Math.min(tiempo, maximo));
const porcentaje = $derived(maximo > 0 ? (mostrado / maximo) * 100 : 0);
const anchoOnda = $derived(Math.max(1, picos.length * 2));
const trazo = $derived(
picos
.map((pico, i) => {
const amplitud = Math.max(4, Math.round((pico / 255) * 88));
const arriba = (100 - amplitud) / 2;
return `M${i * 2 + 1} ${arriba}V${arriba + amplitud}`;
})
.join('')
);
/** Un archivo mal formado no debe romper el control de reproducción. */
function leerPicos(datos: unknown): number[] {
if (!datos || typeof datos !== 'object') return [];
const candidatos = (datos as { picos?: unknown }).picos;
if (!Array.isArray(candidatos) || candidatos.length === 0 || candidatos.length > 512) return [];
return candidatos.every(
(valor) => Number.isInteger(valor) && Number(valor) >= 0 && Number(valor) <= 255
)
? (candidatos as number[])
: [];
}
/*
* Se carga solo la onda de la pista actual. Cambiar de tema cancela la
* petición anterior y el caché HTTP hace que volver a ella no cueste nada.
* El fallo es silencioso porque debajo queda el input de rango de siempre.
*/
$effect(() => {
const fuente = onda;
picos = [];
if (!fuente) return;
const controlador = new AbortController();
void fetch(fuente, { signal: controlador.signal, cache: 'force-cache' })
.then((respuesta) => (respuesta.ok ? respuesta.json() : undefined))
.then((datos) => {
if (!controlador.signal.aborted) picos = leerPicos(datos);
})
.catch(() => {});
return () => controlador.abort();
});
function alMover(evento: Event & { currentTarget: HTMLInputElement }) {
arrastrando = true;
@ -88,39 +42,20 @@
<div class="progreso">
<span class="tiempo" aria-hidden="true">{segundosADuracion(mostrado)}</span>
<div class="carril" class:con-onda={Boolean(onda)} class:apagado={!activa || maximo === 0}>
{#if picos.length > 0}
<svg
class="onda"
data-forma-onda
viewBox={`0 0 ${anchoOnda} 100`}
preserveAspectRatio="none"
style="--avance: {porcentaje}%"
aria-hidden="true"
>
<path class="onda-pendiente" d={trazo}></path>
<path class="onda-escuchada" d={trazo}></path>
</svg>
{:else if onda}
<!-- Reserva el alto mientras llega el JSON y sirve de alternativa si falla. -->
<span class="reserva" aria-hidden="true"></span>
{/if}
<input
type="range"
class="pista"
style="--avance: {porcentaje}%"
min="0"
max={maximo || 1}
step="0.5"
value={mostrado}
disabled={!activa || maximo === 0}
oninput={alMover}
onchange={alSoltar}
aria-label="Posición de reproducción"
aria-valuetext="{segundosADuracion(mostrado)} de {segundosADuracion(maximo)}"
/>
</div>
<input
type="range"
class="pista"
style="--avance: {porcentaje}%"
min="0"
max={maximo || 1}
step="0.5"
value={mostrado}
disabled={!activa || maximo === 0}
oninput={alMover}
onchange={alSoltar}
aria-label="Posición de reproducción"
aria-valuetext="{segundosADuracion(mostrado)} de {segundosADuracion(maximo)}"
/>
<span class="tiempo" aria-hidden="true">{segundosADuracion(maximo)}</span>
</div>
@ -141,77 +76,14 @@
text-align: center;
}
.carril {
position: relative;
flex: 1;
min-width: 0;
}
.carril.con-onda {
height: 2.5rem;
padding: 0.2rem 0.35rem;
border: 1px solid var(--c-border);
border-radius: 0.7rem;
overflow: hidden;
}
.carril.con-onda:focus-within {
border-color: var(--c-focus);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-focus) 45%, transparent);
}
.carril.apagado {
opacity: 0.5;
}
.onda {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.onda path {
fill: none;
stroke-width: 1.35;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.onda-pendiente {
stroke: color-mix(in oklab, var(--c-text-muted) 88%, transparent);
}
.onda-escuchada {
stroke: var(--c-accent);
clip-path: inset(0 calc(100% - var(--avance)) 0 0);
}
.reserva {
position: absolute;
inset-inline: 0.35rem;
top: 50%;
height: 2px;
background: var(--c-bg-sunken);
transform: translateY(-50%);
}
.pista {
display: block;
width: 100%;
flex: 1;
appearance: none;
height: 1rem;
background: transparent;
cursor: pointer;
}
.con-onda .pista {
position: absolute;
inset: 0;
height: 100%;
margin: 0;
}
.pista:disabled {
cursor: default;
opacity: 0.5;
@ -222,7 +94,7 @@
* unica forma de tener "parte reproducida" en un input range sin duplicar
* elementos.
*/
.carril:not(.con-onda) .pista::-webkit-slider-runnable-track {
.pista::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
/*
@ -238,7 +110,7 @@
);
}
.carril:not(.con-onda) .pista::-moz-range-track {
.pista::-moz-range-track {
height: 4px;
border-radius: 2px;
/*
@ -254,7 +126,7 @@
);
}
.carril:not(.con-onda) .pista::-webkit-slider-thumb {
.pista::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
@ -264,80 +136,11 @@
border: none;
}
.carril:not(.con-onda) .pista::-moz-range-thumb {
.pista::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--c-accent);
border: none;
}
/*
* El input sigue cubriendo toda la onda —teclado, ratón y tacto continúan
* siendo nativos—, pero su carril y su pulgar no tapan el dibujo.
*/
.con-onda .pista::-webkit-slider-runnable-track {
height: 100%;
background: transparent;
}
.con-onda .pista::-moz-range-track {
height: 100%;
background: transparent;
}
.con-onda .pista::-webkit-slider-thumb {
appearance: none;
width: 0.8rem;
height: 100%;
margin: 0;
background: transparent;
border: none;
}
.con-onda .pista::-moz-range-thumb {
width: 0.8rem;
height: 100%;
background: transparent;
border: none;
}
.pista:focus-visible {
outline: 2px solid var(--c-focus);
outline-offset: 2px;
border-radius: var(--radius-boton);
}
.con-onda .pista:focus-visible {
/* El foco lo pinta el contenedor; aquí quedaría recortado por la onda. */
outline: none;
}
@media (max-width: 48rem) {
.progreso {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-1) var(--sp-2);
}
.tiempo {
grid-row: 1;
min-width: 0;
text-align: left;
}
.tiempo:last-child {
grid-column: 2;
text-align: right;
}
.carril {
grid-column: 1 / -1;
grid-row: 2;
}
.carril.con-onda {
height: 4rem;
}
}
</style>

@ -111,18 +111,13 @@
{/if}
<div class="datos">
<p class="linea">
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}
>
<a class="titulo" href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}>
{pista.titulo}
</a>
<span class="calidad">Calidad de escucha</span>
</p>
<span class="contexto muted">
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}
>{pista.albumTitulo}</a
>
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}>{pista.albumTitulo}</a>
{#if pista.estiloNombre}
<span aria-hidden="true">&middot;</span>
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: pista.estiloSlug })}
@ -190,7 +185,6 @@
<BarraProgreso
tiempo={reproductor.tiempo}
duracion={reproductor.duracion}
onda={pista.onda}
onbuscar={(s) => reproductor.buscar(s)}
/>
</div>

@ -43,7 +43,6 @@ export function aPista(cancion: Cancion): PistaReproductor {
cancionSlug: cancion.slug,
titulo: cancion.titulo,
audio: cancion.audio ?? '',
onda: cancion.onda,
duracion: cancion.duracion,
...contexto(cancion)
};
@ -61,7 +60,6 @@ export function aPistaDeVersion(cancion: Cancion, version: VersionMusical): Pist
cancionSlug: cancion.slug,
titulo: `${cancion.titulo} · ${version.nombre}`,
audio: version.audio ?? '',
onda: version.onda,
duracion: version.duracion,
...contexto(cancion)
};

@ -33,8 +33,6 @@ export interface PistaReproductor {
titulo: string;
/** URL pública de la canción completa en calidad de escucha. */
audio: string;
/** Resumen ligero de amplitudes que dibuja el buscador del reproductor. */
onda?: string;
albumTitulo: string;
albumSlug: string;
portada: string;

Loading…
Cancel
Save

Powered by TurnKey Linux.