feat(media-player): con peaks el scrubber es onda; sin peaks, ninguna onda

Pasar `peaks` al TimeSlider convierte el scrubber en waveform; sin ellos queda
el raíl. Por debajo son el MISMO Slider sobre el mismo dominio (segundos), así
que sólo cambia la pintura — ni el valor, ni el buffered, ni el teclado, ni la
dirección.

La ausencia de `peaks` es la señal, y no hace falta una prop aparte que declare
que la fuente se streamea: da igual el motivo por el que no haya datos.

## Por qué NO hay onda genérica

Se propuso pintar una sintética cuando la fuente se streamea. Se investigó el
sector antes de implementarlo, con cada afirmación verificada contra fuente
primaria, y no tiene UN SOLO precedente:

- SoundCloud sirve un JSON de 1800 valores (~6,5 KB; O(1) en tamaño, no
  O(duración)).
- WhatsApp y Telegram los calculan en el EMISOR y viajan dentro del mensaje.
  Telegram es el único con contrato público: 100 muestras de 5 bits, LSB-first
  sin alinear, 63 bytes, valores 0-31.
- Spotify y Apple Podcasts no ponen onda: barra normal.
- wavesurfer.js lo zanja en su FAQ: streaming sólo funciona si le entregas
  peaks + duration. No hay decodificación progresiva; para ficheros grandes
  recomienda precálculo en servidor con bbc/audiowaveform.

O sea que una fuente que se streamea no plantea un modo de pintado, sino un
problema de suministro de datos que se empuja al llamador — lo que D-WF.2 ya
decía. Y una forma inventada MIENTE sobre el sonido: sugiere dónde hay silencio
y dónde clímax sin saberlo. La caída honesta es el raíl que el player ya tiene.

La investigación entera queda en el README de eidos, incluida la afirmación que
se CAYÓ al verificarla: que SoundCloud precalcula al transcodificar y no computa
nada en cliente. El blog citado argumentaba lo contrario, y como el JSON viene
cuantizado a una rejilla fija de 1800x140, sí hay reescalado en cliente.

Vía lateral anotada para cuando se retome: Apple Podcasts da textura al scrubber
con capítulos y detentes hápticos — significado, no amplitud.

check 0 errores propios · player + waveform 18/18 · eidos-lint invalid 0 ·
docs:check 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 4755d97d1b
commit 127c4c95b4

@ -213,6 +213,54 @@ slider de volumen lleva fallback al rol que envuelve
(`var(--_mp-fg, var(--color-content-primary))`): sin él, el control sale sin
color dentro del panel.
## Waveform como scrubber — y por qué NO hay onda genérica (2026-08-04)
Pasar `peaks` al `TimeSlider` **convierte el scrubber en onda**; sin ellos queda
el raíl de siempre. Por debajo los dos son el mismo `Slider` sobre el mismo
dominio (segundos), así que lo único que cambia es la pintura — ni el valor, ni
el buffered, ni el teclado, ni la dirección.
El player **no descarga ni decodifica** los picos (D-WF.2). No es pereza: una
fuente que se _streamea_ no se puede decodificar sin bajársela entera, y con
audio de terceros ni eso — medido, `fetch` de `media.w3.org` y `soundhelix.com`
**falla por CORS** mientras el `<audio>` las reproduce sin problema. Los picos
llegan como DATO, precalculados por quien tiene el fichero.
### La decisión: sin picos, scrubber. Nunca una onda inventada
Se propuso pintar una onda **genérica** cuando la fuente se streamea, para que
el componente siguiera sirviendo de scrubber. Se investigó el sector antes de
implementarlo (2026-08-04, cada afirmación verificada contra fuente primaria) y
**la propuesta no tiene un solo precedente**:
| Producto | De dónde salen los picos |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **SoundCloud** | JSON en `wave.sndcdn.com`: `{width:1800, height:140, samples:[1800 enteros]}`, ~6,5 KB. **1800 cubos fijos sea cual sea la duración** — el fichero es O(1), no O(duración) |
| **WhatsApp** | los calcula el **emisor** y viajan dentro del payload cifrado, campo 19 de `AudioMessage`. 64 barras |
| **Telegram** | `documentAttributeAudio.waveform`, el **único con contrato público**: 100 muestras de 5 bits, LSB-first sin alinear, 63 bytes, valores 0–31 |
| **Spotify · Apple Podcasts** | no hay onda. Barra normal |
| **wavesurfer.js** | su FAQ lo zanja: streaming **sólo** funciona si le entregas `peaks` + `duration`. No hay decodificación progresiva. Para ficheros grandes, precálculo en servidor con `bbc/audiowaveform` |
O sea que «fuente que streamea» **no es un modo de pintado, es un problema de
suministro de datos** que se empuja al llamador — que es exactamente lo que
D-WF.2 ya decía. Y una forma inventada **miente sobre el sonido**: sugiere dónde
hay silencio y dónde clímax sin saberlo. Peor que no tener onda.
De ahí que la ausencia de `peaks` **baste como señal**: sin picos, scrubber; con
picos, onda. Sin prop extra que lo declare.
⚠️ **Refutación que salvó la conclusión**: un agente afirmó que SoundCloud
precalcula «al transcodificar» y que nada se computa en cliente. Se cayó al
verificarla — el blog citado argumentaba lo CONTRARIO (entonces SoundCloud no
exponía picos y un tercero los sacaba parseando el PNG), lo de «al
transcodificar» no lo dice ninguna fuente, y como el JSON viene cuantizado a una
rejilla fija de 1800×140, **sí hace falta reescalar en cliente**.
**Vía lateral, para cuando se retome**: Apple Podcasts le da textura al scrubber
con **capítulos + detentes hápticos** — significado, no amplitud. Es el mismo
reparto que la sema ya hace entre canales y datos, y encaja mejor con esta casa
que dibujar una onda falsa.
## Audit exceptions
- **R-1.2 exception:** el estado disabled lo dibujan los primitivos compuestos —

@ -4,6 +4,7 @@
import { createId } from '$active-uix/id';
import { MediaPlayerTimeSliderProvider } from '../media-player-provider.svelte';
import * as Slider from '../../slider';
import * as Waveform from '../../waveform';
import type { MediaPlayerTimeSliderProps } from '../types';
const uid = $props.id();
@ -11,6 +12,7 @@
let {
ref = $bindable(null),
id = createId(uid, 'media-player-time-slider'),
peaks,
children,
child,
'data-size': size,
@ -34,28 +36,50 @@
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
<!-- `dir` comes from the PLAYER, not from the Slider's own config default:
otherwise a player inside an LTR subtree of an RTL app renders LTR chrome
with RTL-anchored sliders. -->
<Slider.Provider
dir={provider.opts.dir?.current}
value={[provider.currentTime]}
min={0}
max={Math.max(provider.duration, 1)}
step={0.1}
secondaryValue={provider.bufferedTime}
aria-label={label}
data-size={size}
onValueChange={([v]) => provider.scrubTo(v)}
onValueCommit={([v]) => provider.seek(v)}
>
<!-- The buffered track IS the Slider's SecondaryRange (G-2): same
{#if peaks}
<!-- Peaks supplied → the scrubber IS the waveform. Both are the same
Slider underneath and share the domain (seconds), so the swap costs
nothing but the painting. With no peaks there is deliberately NO
fallback shape: see the note on the `peaks` prop. -->
<Waveform.Provider
{peaks}
dir={provider.opts.dir?.current}
value={[provider.currentTime]}
min={0}
max={Math.max(provider.duration, 1)}
step={0.1}
secondaryValue={provider.bufferedTime}
ariaLabel={label}
data-size={size}
onValueChange={([v]) => provider.scrubTo(v)}
onValueCommit={([v]) => provider.seek(v)}
>
{@render children?.()}
</Waveform.Provider>
{:else}
<!-- `dir` comes from the PLAYER, not from the Slider's own config default:
otherwise a player inside an LTR subtree of an RTL app renders LTR chrome
with RTL-anchored sliders. -->
<Slider.Provider
dir={provider.opts.dir?.current}
value={[provider.currentTime]}
min={0}
max={Math.max(provider.duration, 1)}
step={0.1}
secondaryValue={provider.bufferedTime}
aria-label={label}
data-size={size}
onValueChange={([v]) => provider.scrubTo(v)}
onValueCommit={([v]) => provider.seek(v)}
>
<!-- The buffered track IS the Slider's SecondaryRange (G-2): same
domain (seconds), painted before the Range so it sits behind the
value fill and the thumb. -->
<Slider.SecondaryRange />
{@render children?.()}
<Slider.Range />
<Slider.Thumb />
</Slider.Provider>
<Slider.SecondaryRange />
{@render children?.()}
<Slider.Range />
<Slider.Thumb />
</Slider.Provider>
{/if}
</div>
{/if}

@ -119,7 +119,32 @@ export type MediaPlayerTimeProps = WithChild<{
}> &
Without<PrimitiveSpanAttributes, {}>;
export type MediaPlayerTimeSliderProps = WithChild<{ id?: string }> &
export type MediaPlayerTimeSliderProps = WithChild<{
id?: string;
/**
* Normalised peaks (`0..1`). **Supplying them turns the scrubber into a
* waveform**; omitting them leaves the plain rail. Both are the same Slider
* underneath, over the same domain (seconds), so nothing but the painting
* changes.
*
* The player never fetches or decodes them (D-WF.2): a streamed source
* cannot be decoded without downloading it whole, and third-party audio
* usually forbids even that — measured, `fetch` of `media.w3.org` and
* `soundhelix.com` fails CORS while `<audio>` plays them fine. So the peaks
* arrive as DATA, precomputed by whoever has the file.
*
* **There is deliberately no generic/synthetic waveform when peaks are
* absent.** Researched against the field (2026-08-04) and nobody paints a
* shape not derived from the real audio: SoundCloud ships a peaks JSON,
* WhatsApp and Telegram compute them on the SENDER and carry them in the
* message, and wavesurfer.js states outright that streaming works only if
* you hand it `peaks` + `duration`. An invented shape lies about the sound —
* it suggests where the silences and the climaxes are without knowing. The
* honest fallback is the rail the player already has. Full record in the
* eidos README, §Waveform.
*/
peaks?: number[];
}> &
Without<PrimitiveDivAttributes, {}>;
export type MediaPlayerVolumeSliderProps = WithChild<{

Loading…
Cancel
Save

Powered by TurnKey Linux.