|
|
|
|
@ -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>
|
|
|
|
|
|