52 KiB
PLAN — Reproductor de sonido (AudioPlayer)
⛔ GATE RECHAZADO — PLAN SUSPENDIDO (2026-07-31). La cuarta presentación se presentó y el usuario la rechazó de raíz: el sistema de sonido sobre el que este plan se apoya está mal planteado — nació de los diseños de otros módulos (sema, media-player) y se parchea en función de lo que había, en vez de partir de su función: orquestador de todos los aspectos del sonido del framework, al nivel de
motion/timers. El sonido se rediseña clean-room como módulo independiente (PLAN-sound-redesign.md) y los consumidores se adaptan a él. Este plan se re-planteará DESPUÉS, sobre el servicio nuevo; nada de lo de abajo se implementa. Las mediciones de F0 (§8.5–§8.7: H-1, H-9, H-10, segmentos 1 de 6) siguen siendo hechos válidos del terreno y el plan nuevo las hereda como conocimiento.El re-plan EXISTE (2026-07-31):
PLAN-audio-player-v2.md— anclado al servicio, con su gate D-AP2.1…13. Este fichero queda como ARCHIVO de los hechos y el análisis que el v2 referencia (§2 matriz · §6 formas · §7 contrato · §8.5–§8.7 evidencia).
Tipo: plan de creación por fases (process — efímero, NO fuente de verdad). Fecha: 2026-07-30 · Estado: 🔏 EN EL GATE, CONSOLIDADO (3.ª pasada) —
$soundexiste y su invariante está medida (PLAN-sound-engine.md); las verificaciones de F0 están HECHAS, con su evidencia (§8.5 · §8.6 · §8.7). Decisiones D-AP.1…D-AP.13 SIN FIRMAR — y el gate ya no se mueve: tres pasadas lo movieron tres veces (D-AP.7 corregida dos veces, D-AP.12 → v2, D-AP.13 al final); la cuarta presentación se firma o se rechaza, no se re-excava. No se escribe una línea de código antes de la firma. Kickoff para sesión nueva: "Leedocs/process/PLAN-audio-player.mdy presenta el gate de §4 — las verificaciones ya están hechas; re-auditar antes de la firma está prohibido."✅ Correcciones incorporadas (sesión 2026-07-30)
Las 5 correcciones que colgaban de esta cabecera ya están en el cuerpo:
# Corrección Dónde aterrizó 1 Faltaba la reproducción de segmentos entera — y Vidstack la tiene ( clipStartTime/clipEndTime), luego es paridad pendiente, no superaciónfila nueva en la matriz §2 · D-AP.12 · contrato §7.5 · §10 2 G-2 (pista secundaria del Slider) sube a BLOQUEANTE de v1: tres consumidores (buffer · ventana de clip · capítulos)§8 · F1 3 D-AP.11 se reescribe contra el art: su premisa («no hay motor») era falsa D-AP.11 en §4 4 Dos invariantes al contrato: prefs.soundnunca toca el volumen del contenido · el silencio de UI debe alcanzar alSlidercompuesto§7.6 5 D-AP.7 deja de ser regla de cascada y pasa a ducking, capacidad del motor D-AP.7 reescrita Y las de la segunda pasada (F0, 2026-07-30), también ya en el cuerpo: H-1 ✅ con stamp real (→ G-4) · H-9:
sustain-loadinges un evento sin emisor (decisión en D-AP.10) · H-10 medido y re-diagnosticado → G-5 (el commit debe disparar en el RESULTADO, no en el clic) · segmentos = 1 de 6 → D-AP.12 pasa a v2 · D-AP.7 corregida dos veces (mecanismo: resta de gain, nochannels; criterio: incongruencia, no frecuencia) · D-AP.13 nueva: el scrubber duplica el contrato delSlider(§8.7).Antes de escribir una línea de código: presentar al usuario las decisiones de §4 y obtener firma. Lo de aquí son propuestas razonadas, no decisiones tomadas — regla A32 (
component-guide.md) + §0.5 decomponent-audit.md("Get the user's sign-off on scope before writing any code").Precondición cumplida: corpus doctrinal leído completo antes de auditar (regla dura
feedback_read_full_doctrine_before_auditing): README · overview · active-architecture · CANON · morfo · sema · soma(+architecture) · eidos · active-uix · active-app · packs · blocks · agent · TSC · recipe-contract · vocabularies · component-guide · completion-checklist · component-audit · demo-authoring · theming(reference/guide/notes/motion/motion-guide/channels/ gradient-finish) · los 7 RFCs · book-deviations · design-text-effects · design-chat-block · glossary · comparison · authoring · testing-and-tooling · next-features · decisions · book-map · guía histórica · veredictos/_system/ _naming/_cierre de la auditoría ·eidos/components/README.md·arts/README.md.
0. Contexto — qué existe hoy y qué NO
El ecosistema ya tiene un reproductor: media-player, genérico
<video> + <audio>, cerrado como PASS en la matriz de aceptación. Y desde el
2026-07-30 tiene además motor de audio propio — el art
$sound, extraído de sema/chans/sound.ts,
con la invariante de un solo AudioContext cerrada y medida. Eso responde una
pregunta que este plan traía mal planteada (D-AP.11) y no añade trabajo al
v1: el player decide cuándo engancharse, no dónde vive el motor.
| Capa | Estado | Evidencia |
|---|---|---|
| morfo | 534 líneas · 17 partes · 12 eventos · expression: 'pack' · apg: 'none — …' |
morfo/components/media-player.ts |
| soma | provider request-driven + puerto MediaProvider + adaptador nativo + 14 wrappers + test 9/9 |
soma/components/media-player/ |
| sema | pack «voz de transporte contenida» (3 reglas) | sema/components/media-player.ts |
| eidos | recipe Lumière (11.7 KB) + 17 wrappers + DefaultControls |
eidos/components/media-player/ |
| demo | 9 pestañas, grupo Media del sidebar | web/routes/uix/components/media-player/+page.svelte |
El modo audio existe, pero es un reproductor de vídeo degenerado. Todo el soporte «audio» del catálogo son tres reglas CSS:
media-player.css:79 [data-media='audio'] [data-media-player-media] → height: 0
media-player.css:161 [data-media='audio'] [data-media-player-title] → position: static
media-player.css:191 [data-media='audio'] [data-media-player-controls] → position: static
Es decir: se colapsa el vídeo a cero y se desabsolutizan dos cajas. No hay nada de lo que hace que un reproductor de sonido sea un reproductor de sonido: identidad de la pieza (carátula · título · artista), waveform, velocidad de reproducción visible, capítulos, formas compactas, integración con el SO.
0.1 Hallazgos de la auditoría del estado actual (evidencia leída)
| ID | Hallazgo | Evidencia | Consecuencia |
|---|---|---|---|
| H-1 ✅ CONFIRMADO en navegador (F0) | Regla sema muerta. El pack construye su selector sobre provider (semaSelector(morfo,'provider',{eventName:'commit-toggle-play'}) → [data-media-player][data-event='commit-toggle-play']) pero el evento estampa en su propio botón (target: v.partRef('play-button')). |
sema/components/media-player.ts:23 vs morfo/…/media-player.ts:95 · stamp medido: data-event="commit-toggle-play" aterriza en <button data-media-player-play-button> |
La firma de play/pause nunca se aplica. 1 de las 3 reglas del pack: commit-complete y commit-fail sí apuntan a provider y funcionan. Fix de una línea ('play-button'), pero cambia comportamiento (empieza a sonar donde no sonaba) → G-4, tras la firma. |
| H-2 | SettingsButton y Captions declarados e inertes ("render inertly until implemented", README soma:99-100). |
soma/…/README.md:96 |
El menú velocidad/pista es justo lo que más pesa en audio (podcast). Deuda declarada que este plan recoge. |
| H-3 | Slider no tiene pista secundaria. Partes: Provider · Range · Thumb · Tick. El buffer del player es un div eidos-only que lee --media-buffered. |
morfo/components/slider.ts |
Un waveform-seek necesita el mismo hueco. Gap de framework (§8). |
| H-4 | Slider no declara aria-valuetext. Solo aria-valuemin/max/now. |
morfo/components/slider.ts:107-109 |
El lector de pantalla anuncia «127», no «2:07 de 41:15». Fallo de nivel de referencia en el control MÁS importante del player. |
| H-5 | Formato de tiempo hecho a mano. formatTime() con padStart local, fuera de uix.format / $libs/days. |
soma/…/media-player-provider.svelte.ts:34-40 |
Contradice el eje de servicios (i18n · dígitos no arábigos · RTL). |
| H-6 | No hay --media-progress. Se eliminó por código muerto; solo sobrevive --media-buffered. |
README eidos:47 | Un waveform necesita el progreso como var continua otra vez (o su propio mecanismo). Decisión, no accidente. |
| H-7 | Cero integración MediaSession en todo el repo. | grep -rn mediaSession src/ = 0 |
Sin controles de pantalla de bloqueo / teclas de medios del SO. |
| H-8 | La carátula (Poster) es position:absolute; inset:0; object-fit:cover sobre una caja 16/9. En audio esa caja no existe (media a height:0). |
media-player.css:85-93 |
La carátula de un álbum no tiene hogar geométrico hoy. |
Ninguno de los ocho invalida el media-player: son exactamente la superficie
que un modo audio de nivel de referencia obliga a cerrar.
1. Qué es —de verdad— un reproductor de sonido
No es «un player sin imagen». Cambian tres cosas de raíz:
- La superficie deja de ser el contenido. En vídeo el marco es la obra y el chrome se esconde encima (scrim, auto-hide). En audio no hay marco: la UI es lo único visible, así que pasa de transparente a persistente, y su trabajo cambia de «no estorbar» a «identificar la pieza».
- La identidad sustituye al fotograma. Carátula, título, artista/serie,
capítulo. Ninguna existe hoy más allá de un
Titlede una línea. - La línea de tiempo se vuelve legible. En vídeo el scrubber se previsualiza con miniaturas; en audio la referencia del sector es la onda (SoundCloud, wavesurfer) o los capítulos (podcast). La barra desnuda es el mínimo, no el estándar.
Y aparecen tres afordancias que en vídeo son secundarias y aquí son de primera
fila: velocidad (podcast/curso), ±15/30 s (ya existe como SeekButton)
y continuidad con el SO (pantalla de bloqueo, auriculares, coche).
2. Comparativa contra referencias (F-1.2 exige ≥3; aquí 8 + la plataforma)
Consultado 2026-07-30. La columna «UIX hoy» = media-player en media="audio".
| Capacidad | Vidstack (DefaultAudioLayout) |
Media Chrome (<media-controller audio>) |
Plyr | Red Hat rh-audio-player |
wavesurfer.js 7 | react-h5-audio-player | UIX hoy | Veredicto v1 |
|---|---|---|---|---|---|---|---|---|
| play/pause · ±seek · mute · volumen · tiempo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| Layout de audio propio (no vídeo colapsado) | ✅ DefaultAudioLayout |
✅ atributo audio |
✅ preset audio | ✅ layout |
n/a | ✅ | ❌ 3 reglas CSS | implementar |
| Formas / tamaños (full · compact · mini · barra) | ⚠️ smallLayoutWhen (2 tamaños) |
⚠️ composición manual | ❌ | ✅ full/compact/mini | n/a | ⚠️ | ❌ | implementar |
| Identidad: carátula · título · artista | ⚠️ Title+Poster |
⚠️ slots | ⚠️ | ✅ series/title/poster | ❌ | ✅ header | ⚠️ solo Title |
implementar |
| Velocidad de reproducción (UI) | ✅ menú + SpeedSlider |
✅ media-playback-rate-button |
✅ settings.speed | ✅ | n/a | ❌ | ❌ (setPlaybackRate existe, sin UI) |
implementar (cierra H-2) |
| Waveform | ❌ | ❌ | ❌ | ❌ | ✅ (el núcleo) | ❌ | ❌ | componente propio (D-AP.4) |
Segmentos (reproducir start…end de una fuente) |
✅ clipStartTime / clipEndTime (recalcula el rango seekable) |
❌ | ❌ (markers sólo marca) |
❌ | ⚠️ Regions (plugin) | ❌ | ❌ | decisión — ver D-AP.12 |
| Capítulos | ✅ SliderChapters + ChapterTitle + radio-group |
⚠️ vía cues | ✅ markers |
✅ transcript/cues | ✅ Regions | ❌ | ❌ | diferir v2 |
| Transcripción | ⚠️ captions | ⚠️ | ❌ | ✅ rh-transcript+rh-cue |
❌ | ❌ | ❌ | diferir v2 |
| Cola / playlist | ❌ | ❌ | ❌ | ❌ | ❌ | ⚠️ skip prev/next | ❌ | fuera (D-AP.6) |
| MediaSession (SO) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | superación (D-AP.5) |
| Ganancia >100 % | ✅ AudioGainSlider |
❌ | ❌ | ❌ | ⚠️ Envelope | ❌ | ❌ | diferir v2 |
| Loop / descarga | ⚠️ | ⚠️ | ✅ | ⚠️ | n/a | ✅ loop | ⚠️ nativo | loop v1 (prop), descarga = app |
| Teclado completo | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅ (9 teclas) | ✅ |
aria-valuetext hablado en el scrubber |
✅ | ✅ | ✅ | ✅ | ❌ | ⚠️ | ❌ (H-4) | gap de framework (§8) |
| Motor enchufable (HLS/DASH/embed) | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ puerto MediaProvider |
✅ ya por encima |
| Semántica perceptiva (familia · intent · sonido/háptica) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ pack sema | ✅ exclusivo |
| Contrato declarativo verificable (morfo + guards) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ exclusivo |
Lectura. El transporte ya está a la altura (y el puerto de motor, por encima). Lo que falta es todo lo que distingue audio de vídeo: layout propio, formas, identidad, velocidad, onda, segmentos. Dos capacidades que ninguna referencia tiene ya son nuestras (sema + morfo), y una que ninguna de las ocho implementa —MediaSession— es la palanca barata de superación.
Las celdas ? de Segmentos se cerraron en F0 (2026-07-30), y el resultado
cambia el veredicto: no es paridad, es 1 de 6. Media Chrome no tiene
componente de clip en su main (revisados src/js y src/js/experimental; el
media-clip-selector que circula en resultados de búsqueda es de una versión
0.x retirada) · Plyr sólo tiene markers, que marcan sin acotar · el
rh-audio-player de Red Hat no lo documenta · react-h5-audio-player no lo
lleva en sus props. Sólo wavesurfer se acerca, y por plugin (Regions). Detalle
y consecuencia en §8.5.
Fuentes: Vidstack components · Vidstack Default Layout · Media Chrome · Plyr · Red Hat audio player · wavesurfer.js · react-h5-audio-player · MDN MediaSession · web.dev Media Session · W3C WAI media players.
3. La tensión arquitectónica (leerla antes de las decisiones)
Dos reglas del propio proyecto apuntan en direcciones opuestas:
- P-4 (norma de pickers,
architecture/eidos.md): «There are no per-variant components (MonthPicker,HourPicker). Those forms are<X kind='month'>». → un reproductor de audio es<MediaPlayer media="audio">. - Regla de admisión (packs/blocks): lo que tiene superficie de contrato propia se promueve a componente canónico y se compone. → la onda, el menú de velocidad y la identidad podrían querer casa propia.
La resolución no la dan las reglas sino las dos referencias primarias, que
coinciden: Vidstack mantiene UN <media-player> y cambia el layout
(DefaultAudioLayout); Media Chrome mantiene UN <media-controller> y cambia
la composición de la barra (atributo audio). Ninguna bifurca el motor.
Y el catálogo ya tiene el precedente exacto para «una segunda raíz visual sobre
el mismo contrato headless»: Toast / Toaster — «two independent roots (not
nested)… Toaster is NOT attached as Toast.Toaster because it is a competing
root, not a child» (architecture/eidos.md §Special case). DefaultControls
ya es, hoy, una composición eidos-only del mismo tipo.
4. Decisiones (gate del usuario — SIN FIRMAR)
| ID | Cuestión | Opciones | Recomendación |
|---|---|---|---|
| D-AP.1 | Pertenencia: ¿componente nuevo o modo del existente? | (a) un motor, un contrato: media-player conserva morfo/soma/sema y gana partes audio-only optional: true + un layout eidos · (b) audio-player nuevo que compone media-player (precedente picker/A27) · (c) fork con su propio provider |
(a). Duplicar transporte + puerto + teclado + eventos es exactamente la reinvención que el §4 del component-guide prohíbe; las dos referencias primarias hacen (a); el morfo YA declara data-media: ['video','audio'] y ya marca optional: true todas las partes no-núcleo (PipButton/FullscreenButton son video-only con ese mismo mecanismo — no hace falta inventar nada). (b) añade una capa de indirección sin contrato nuevo. |
| D-AP.2 | Puerta pública: ¿cómo se pide un reproductor de sonido? | (a) <MediaPlayer media="audio"> + <MediaPlayer.AudioLayout/> (espejo de DefaultControls) · (b) raíz competidora <AudioPlayer> en eidos sobre el mismo soma (precedente Toast/Toaster) · (c) ambas |
(c): AudioLayout como parte eidos-only (composición explícita, coherente con DefaultControls) y <AudioPlayer> como raíz competidora exportada aparte —no como MediaPlayer.AudioPlayer— que la monta con media="audio". El 90 % del uso es una etiqueta; el 10 % compone partes. Coste morfo/soma/sema: cero. |
| D-AP.3 | Formas y variaciones (petición explícita del usuario) | (a) solo size canónico · (b) variant propio de componente · (c) variant + size + responsive |
(c). variant: 'card' | 'row' | 'bar' | 'inline' como variante específica de componente —vía legítima y con precedente escrito (Banner: inline|overlay|persistent, Spinner: bars|dots|ring, theming §19)— declarada en su types.ts, NO en EIDOS_VARIANTS. size = subconjunto canónico xs..xl (la recipe ya lo mapea). Ambas admiten ResponsiveProp<T>. Detalle en §6. |
| D-AP.4 | Waveform | (a) componente canónico Waveform propio (ruta de 9 fases) · (b) parte eidos-only del player · (c) fuera |
(a), pero en su propia iniciativa, DESPUÉS del v1. Tiene superficie de contrato real (es un role="slider" con teclado, data-* y eventos handle-*), y sirve fuera del player (nota de voz en chat-message, editor). Meterlo como parte eidos-only sería crecerle el privilegio al player — el antipatrón que la regla de admisión existe para impedir. Los picos: peaks: number[] que aporta la app (ruta profesional: BBC audiowaveform/SoundCloud) con decodificador cliente opcional; el renderer reutiliza $libs/plots (scaleLinear + area/line), no inventa matemáticas. |
| D-AP.5 | MediaSession (SO) | (a) prop opt-in en el provider soma · (b) art nuevo $media-session · (c) app-land |
(a) opt-in, apagado por defecto, mediaSession={{ title, artist, album, artwork }}. Es exactamente el snapshot que el provider ya posee (playbackState, setPositionState, seekto), y dejarlo fuera obliga a cada consumidor a re-derivarlo. Riesgo declarado: navigator.mediaSession es singleton de documento — dos players activos se pisan; el provider debe registrar solo mientras !paused y liberar en pause/dispose. (b) es sobreingeniería para una API sin estado propio. Ninguna de las 8 referencias lo trae → superación barata. |
| D-AP.6 | Cola / playlist | (a) en el player · (b) block/app-land | (b), fuera de alcance. Una cola es una composición de Listbox + estado de aplicación: cero contrato nuevo → tier blocks o app. El player expone commit-complete y ya. Se registra en next-features.md. |
| D-AP.7 | Firma sema en modo audio — reescrita 2026-07-30 (corrección 5) | (a) heredar el pack actual · (b) silenciar el canal sonoro en audio · (c) silencio mientras suena + ducking cuando no basta | (c). El suelo sigue siendo (b) y su doctrina no cambia: D.7 (book-deviations) «En eventos frecuentes, la prioridad es evitar fatiga. El silencio es una firma válida» + antipatrón cap. 34 §10 «sonido decorativo» — si el contenido ES audio, un tick de UI compite con la obra. MECANISMO CORREGIDO tras leer D.5/D.7 — la propuesta original decía channels: ['haptic'], y eso viola la regla dura de la cascada (CLAUDE.md: «add character, never the intent's evaluative profile… to shift one, compose with { op: 'add', value: … }»): tirar el canal entero borra también la carga evaluativa, así que un commit-fail dejaría de oírse justo cuando más falta hace. El catálogo ya tiene el patrón canonizado (D.5): form.toggle.silent y tooltip.silent restan exactamente el gain de la familia, de modo que lo cotidiano queda en 0 y los deltas de intent afloran. Aplicado aquí, con el criterio final: INCONGRUENCIA, no frecuencia (D.7: «si el contenido ES audio, un tick de UI compite con la obra»; con frecuencia como criterio, play/pausa —2 a 10 veces por escucha— quedaría fuera y la línea sería arbitraria): se cancela el gain de lo que suena MIENTRAS suena la obra — el transporte (commit-toggle-play, commit-toggle-mute, y el commit-set del scrubber vía la regla de descendientes de §7.6) y el contact-activate de los botones compuestos — y se deja intacto lo que informa del estado de la obra misma: commit-complete y commit-fail. De los dos, commit-complete es el discutible (su cadencia depende del contenido: una vez por episodio, muchas en una nota de voz en bucle) — se firma con esa nota. Dato que obliga a esa precisión: risk no añade gain (sólo threat +0.1 y fulfill +0.05), así que una cancelación global también silenciaría el fallo de carga. Lo que cambia es que el silencio ya no tiene que ser total ni de cascada: $sound es dueño del earcon, así que atenuar la UI mientras suena la obra no necesita que el player consuma el grafo — basta con que el provider baje el master (uix.sound.setMasterGain) mientras !paused y lo restaure al pausar. Regla de reparto: la cascada decide cuándo callar, el motor decide cuánto. Lo que sí exige el grafo (D-AP.11(b), diferido) es lo contrario: agachar el contenido bajo un earcon, o hacerlo con precisión de muestra. A firmar con la decisión: el master es global, así que hay que decidir quién lo restaura y qué pasa con dos players a la vez — si no se resuelve, el v1 se queda en (b) puro, que ya es correcto. |
| D-AP.8 | Capítulos + transcripción | (a) v1 · (b) v2 · (c) fuera | (b) v2, registrado con disposición en el README (F-1.4). Capítulos = pista secundaria del scrubber → depende de D-AP.4/H-3; transcripción = superficie de contenido con su propia a11y (candidata a componente propio, patrón Red Hat). |
| D-AP.9 | Responsive del layout | (a) ResponsiveProp (mecanismo del framework) · (b) container queries del TSC · (c) ambos |
(a) en v1, (b) evaluado en F6. El TSC ya tiene el eje container: {} documentado como «a themeable axis with 0 consumers today — an open cage» (canon/tsc.md): un player que vive dentro de un aside, una tarjeta o un pie de página es el primer consumidor natural del catálogo, y resolvería mejor que el smallLayoutWhen de Vidstack (que depende del contenedor, no del viewport). Abrir la jaula es un entregable con valor propio — pero no debe bloquear el v1. |
| D-AP.10 | Alcance del v1 | (a) paridad estricta con las referencias · (b) paridad + superación (MediaSession + silencio sema + container) | (a) + MediaSession + silencio sema; container, waveform y —tras §8.5— segmentos como iniciativas hermanas. Criterio: el usuario pidió «como mínimo al mismo nivel» — la paridad es el suelo, no el techo, y las dos superaciones baratas caben. Añadido en F0: hay que decidir además qué se hace con sustain-loading (H-9), que hoy es un evento declarado sin emisor — cablearlo o retirarlo. |
| D-AP.11 | ¿El player consume $sound? — escrita 2026-07-30 contra el art ya existente; su premisa anterior («no hay motor, ¿dónde lo ponemos?») era falsa |
(a) no consumirlo en v1: el player sigue sobre <audio> nativo y $sound sólo suena la UI · (b) consumirlo desde v1: colgar el MediaElementAudioSourceNode de uix.sound.context · (c) consumirlo sólo cuando el consumidor lo pida (prop opt-in) |
(a) en v1, (b) como iniciativa hermana con Waveform. El art existe y la invariante de contexto único está cerrada y medida (PLAN-sound-engine.md §15), así que la pregunta ya no es dónde vive el motor sino cuándo engancharse. Enganchar <audio> a Web Audio no es gratis: createMediaElementSource redirige el audio del elemento al grafo de forma irreversible, y con una fuente cross-origin sin CORS lo deja MUDO. Un v1 cuyo trabajo es layout, identidad, formas, velocidad y segmentos no necesita el grafo; quien lo necesita es la onda, el visualizador, la ganancia >1 y el ducking del contenido — todos diferidos. Consecuencia declarada: con (a), el ducking de D-AP.7 sólo puede atenuar la UI (setMasterGain), nunca la obra; y el punto de bucle del clip (D-AP.12) sigue atado a timeupdate. Lo que sí entra en v1 es la regla escrita: si algún día se engancha, es a uix.sound.context, jamás a un new AudioContext() propio. |
| D-AP.12 | Segmentos (clip) — fila nueva 2026-07-30; recomendación CAMBIADA tras verificar §8.5: se presentó como «paridad, Vidstack los trae» y la medición dice 1 de 6, no paridad |
(a) v2, con el diseño de §7.5 congelado · (b) clip = { start, end, loop? } en el v1 · (c) regions genéricas |
(a) v2 — cambio de criterio con los datos delante: sólo Vidstack lo trae (Media Chrome, Plyr, Red Hat y react-h5 no; wavesurfer por plugin), así que no es el suelo que justificaba meterlo en un v1 ya grande; su única implementación arrastra un bug móvil abierto que no podemos reproducir aquí; y la capa de proyección absoluto↔relativo toca el Time, el aria-valuetext y el dominio del slider — superficie de bug en el control más importante. Diferir es barato y no acorrala: sin clip, el dominio es 0…duration y el tiempo es el de la fuente; añadirlo luego es un modo nuevo, no un cambio de significado para quien ya lo usa. Si tienes un caso de uso concreto hoy, la opción (b) sigue en pie con este diseño: capa de proyección absoluto↔relativo (dominio del slider 0…end−start, timeBase: 'clip' | 'source' en Time, misma base en aria-valuetext) · clamp en el provider, no en el puerto (el puerto MediaProvider no se contamina: sigue hablando de la fuente entera) · commit-complete dispara en el fin del clip — mismo evento, frontera movida · con loop NO hay commit-complete: un bucle esperado no es una ocurrencia (regla B.5). Limitación declarada: timeupdate llega ~4 veces/s, así que sin Web Audio el punto de bucle es impreciso (~±125 ms) — se documenta, no se disimula. Riesgo a probar en F0: bug abierto de clip en Chrome/Safari móvil (vidstack#1195). (c) queda descartado y chapters v2, los dos por depender de G-2. |
| D-AP.13 | El scrubber duplica el contrato del Slider — fila nueva tras resolver la postura de participación (§8.7) |
(a) status quo: cada arrastre emite commit-set (Slider) y commit-set-time (player) · (b) retirar commit-set-time del morfo y delegar en el commit-set del Slider embebido · (c) callar el del Slider |
(b). Son el mismo suceso declarado dos veces: onValueCommit → seek() → trigger('commit-set-time') (time-slider:45 + provider:322 — dos líneas leídas; el commit-set del Slider, medido en navegador). Es literalmente el rationale de radio-cards («declararlos aquí duplicaría el contrato», doctrina de participación, postura 3), y el propio provider ya practica el patrón: setVolume no emite — «the perceptual signal is the composed Slider's». (c) es imposible sin degradar el Slider para todo el catálogo. Coste: retirar el evento del morfo en F2, con su test — cambio de contrato de un componente PASS, declarado. |
5. Arquitectura propuesta (tras firma de D-AP.1/2)
morfo/components/media-player.ts +6 partes optional (audio-only) · +2 eventos · +1 texto
│ (contrato ÚNICO — no se bifurca)
soma/components/media-player/ +sub-providers de las partes nuevas
│ +MediaSession opt-in en el provider raíz
│ +clip (§7.5) SÓLO si D-AP.12(b) — recomendación: v2
│ +formatTime → uix.format (H-5)
sema/components/media-player.ts +regla de silencio en audio (D-AP.7) · fix H-1
│
eidos/components/media-player/ +audio-layout.svelte (composición, espejo de DefaultControls)
│ +recipe: variantes card/row/bar/inline
eidos/components/audio-player/ raíz competidora <AudioPlayer> (Toast/Toaster)
Lo que NO se toca: el puerto MediaProvider (ya es el acierto del
componente), el modo vídeo, el recipe Lumière existente, la demo de
media-player.
Dogfooding obligatorio (§4 component-guide): la barra compone IconButton
(ya lo hace), los sliders componen Slider (ya), el menú de velocidad compone
DropdownMenu, la carátula compone Image + AspectRatio, la identidad
compone Text/Heading, el estado vacío compone EmptyState. Cero
primitivas inline.
6. Formas y variaciones (el punto 3 del encargo, en detalle)
Cuatro formas, un solo contrato. La variante decide qué partes monta el layout y cómo se disponen, nunca qué puede hacer el player.
variant |
Forma | Partes que monta | Contexto de uso | Referencia del sector |
|---|---|---|---|---|
card |
Tarjeta vertical: carátula grande arriba, identidad, scrubber, transporte centrado | Artwork · Title · Artist · TimeSlider · Time×2 · Play · Seek± · Mute · Volume · Rate | Página de episodio, ficha de álbum, landing | Apple Podcasts / Bandcamp |
row |
Fila horizontal: carátula pequeña a la izquierda, identidad + scrubber apilados | Artwork(sm) · Title · Artist · TimeSlider · Play · Seek± · Rate | Lista de episodios, resultado de búsqueda, feed | SoundCloud track |
bar |
Barra persistente a ancho completo (composición pensada para sticky/pie) |
Artwork(xs) · Title · Play · Seek± · TimeSlider · Time · Mute · Volume · Rate | Reproductor global de la app | Spotify bottom bar |
inline |
Mínima, en línea con el texto: play + scrubber + tiempo | Play · TimeSlider · Time | Nota de voz en un chat, cita de audio en un artículo | mensaje de voz de WhatsApp |
Ejes ortogonales que componen con la variante:
size(xs..xl,ResponsiveProp) — densidad; la recipe ya mapea el bundle--size-{k}-*. Regla dura:mdno cambia de significado por viewport (theming §5); lo que cambia es qué size se elige.color— el sistema completo (rol / intent / 33 escalas / CSS crudo =ComponentColorProp), sin subconjuntos: decisión «abrir la jaula del color» (theming §25, guard enrecipe-css-contract.test.ts).variantresponsive —variant={{ base: 'inline', md: 'row', lg: 'card' }}.data-depth— la barra persistente estampa su plano (raised/overlay), no inventa sombras (contrato de recipe R-4.1).
Regla de forma (espejo de la que rige blocks): la variante no puede
añadir comportamiento. Si una forma necesitase un evento, un data-* que el
CSS deba seleccionar o una obligación a11y nueva, eso se promueve al contrato
—no se le crece el privilegio al layout.
7. Contrato propuesto (borrador para F1 — se cierra con la firma)
7.1 Partes nuevas (todas optional: true, audio-only)
| Parte | kebab | archetype | Elemento | Rol |
|---|---|---|---|---|
Artwork |
artwork |
image |
img |
Carátula. Compone Image+AspectRatio; alt real (no decorativa como Poster, porque en audio es la única representación visual de la obra). |
Artist |
artist |
description |
div |
Autoría / serie. |
RateButton |
rate-button |
trigger |
button |
Abre el menú de velocidad (compone DropdownMenu) o cicla el preset. Cierra H-2 junto a SettingsButton. |
Identity |
identity |
group |
div |
Agrupador de Artwork+Title+Artist (le da a la variante un bloque que mover). |
Transport |
transport |
group |
div |
Agrupador de los botones de transporte, hermano de Controls. |
LiveIndicator |
live-indicator |
indicator |
div |
Emisión en directo (data-live ya existe en el provider). |
7.2 Eventos nuevos
| Evento | Familia · verbo | Intent | Target | sequence |
Por qué |
|---|---|---|---|---|---|
commit-set-rate |
commit · set |
neutral |
rate-button |
post |
Fijar velocidad es un valor aplicado (commit.set, BOOK_CANON A.5: «un valor, criterio o parámetro ha quedado aplicado» — mismo caso que sort/slider). |
sustain-end |
sustain · end |
— | provider |
coincident |
La pregunta estaba mal planteada: sustain-loading no se limpia ni deja de limpiarse porque NUNCA SE EMITE. No hay un solo trigger('sustain-loading') en el provider (sólo commit-complete, commit-fail, commit-toggle-play, commit-set-time, commit-toggle-mute, shift-*, commit-toggle-captions), y el README de soma afirma que «clears on canplay» un ciclo que no existe. No se puede cerrar un proceso que no se abre → H-9. |
Nada más. loop es prop nativa sin evento (B.5). El cambio de carátula/título es
dato, no ocurrencia.
7.3 Teclado
Se hereda el contrato del provider (9 teclas). Se añaden, alineadas con el
sector (j/l de YouTube, </> de velocidad):
| Tecla | Acción | Nota |
|---|---|---|
j / l |
seek ∓ seekStep |
alias de ←/→, convención del sector |
< / > |
bajar / subir velocidad | requiere RateButton montado |
0–9 |
saltar al 0–90 % | solo cuando duration es finita (no en directo) |
7.4 A11y — el listón
aria-valuetexthablado en el scrubber (cierra H-4): «2 minutos 7 segundos de 41 minutos», no «127». Es gap de framework (§8).Artworkconaltreal; el resto de la identidad como texto real (nuncaaria-labelsobredivs vacíos).commit-failya declararequiresLiveRegion+reducedMotionFallback: 'text'— se conserva.- Contraste: la barra
barsobre superficie arbitraria consumedata-depth, no color inventado (R-2.1/R-4.6). - Sin robo de foco al cambiar de pista.
7.5 Segmentos (clip) — el contrato (D-AP.12)
| Pieza | Regla |
|---|---|
| Prop | clip={{ start, end, loop? }} en el provider soma. Ausente = fuente entera (comportamiento de hoy, sin cambios) |
| Puerto | MediaProvider no se toca: sigue hablando de la fuente entera. El clamp y la proyección viven en el provider |
| Dominio del slider | 0…end−start. Un scrubber que empieza en 0 es la única lectura honesta para quien no sabe que hay una fuente mayor detrás |
Time |
timeBase: 'clip' | 'source' — y aria-valuetext usa la misma base que el Time visible. Dos bases distintas en la misma pantalla es un fallo de a11y, no una opción |
| Fin | commit-complete dispara al llegar a end — el mismo evento, la frontera movida |
loop |
NO dispara commit-complete: un bucle esperado no es una ocurrencia (B.5) |
| Pista secundaria | La ventana del clip se pinta en la pista secundaria de Slider — G-2, uno de sus tres consumidores |
| Limitación | timeupdate llega ~4 veces/s: sin Web Audio el punto de bucle es impreciso (~±125 ms). Se declara en el README, no se disimula |
7.6 Dos invariantes que este componente es el primero en necesitar
prefs.soundNUNCA toca el volumen del contenido. Gobierna el sonido de UI (sema: earcons,off/reduce). Un reproductor de sonido es el primer componente del catálogo donde «sonido» significa dos cosas, y confundirlas silenciaría la obra al bajar una preferencia de interfaz. El volumen del contenido esvolume/muteddel provider, y nada más.- El silencio de UI en audio (D-AP.7) tiene que alcanzar al
Slidercompuesto — ✅ CONFIRMADO en F0. ElTimeSlidercomponeSlider.Provider(media-player-time-slider.svelte:37), y el provider del slider adjuntaresolveSliderDragSound(...)como override en cadahandle-drag(slider-provider.svelte.ts:231). Stamp medido arrastrando el scrubber:handle-dragycommit-setsobre[data-slider]. Con el sonido activo, el scrubber sonoriza por encima de la obra. La regla[data-media-player][data-media='audio'] [data-slider]es correcta y funciona:Element.matches()evalúa descendencia. ⚠️ 2026-08-12: la mecánica citada cambió el 2026-08-06 — el slider ya no adjuntaresolveSliderDragSound(RETIRADO,AUDIT-docs-code-ledger.md§D10) sino el nombre'step'; la conclusión (el silencio de UI alcanza al Slider compuesto) se sostiene igual, hoy porchannels: ['haptic'](D-AP2.7 v2). - Y no basta con el
Slider(hallazgo nuevo de F0, H-10): el botón de play emite dos señales por clic —contact-activatedelButtoncompuesto ycommit-toggle-playdel player, ambas estampadas en el propio<button>. Una regla de silencio que seleccione el provider no alcanza a ninguna: el silencio en audio debe cubrir los descendientes. Y no son dos estampas con un solo sonido — medido: dos earcons (sound-e2e.test.ts, 4 osciladores). Detalle en §8.6.
8. Gaps de framework a promover ANTES (regla de admisión)
Se construyen primero, en el canon, y el player los compone. Ninguno es un parche local del player.
| # | Gap | Dónde | Justificación 2-de-3 | Coste |
|---|---|---|---|---|
| G-1 | aria-valuetext en el morfo de Slider (v.propRef('valueText')) + prop valueText en soma |
morfo/components/slider.ts + provider |
aria es campo morfo por definición (soma emite, eidos puede seleccionar) |
bajo |
| G-2 🔴 BLOQUEANTE de v1 | Pista secundaria («buffered»/«secondary range») en Slider |
morfo + soma + recipe |
Lo consumen soma (valor) y eidos (pintura) → 2-de-3 ✓. Y ya tiene tres consumidores, no uno: el buffer, la ventana del clip (D-AP.12) y los capítulos (v2). Hoy lo simula un div eidos-only en media-player y lo simularía otro en Waveform — dos copias = la señal de que falta la pieza; tres = que bloquea |
medio |
| G-3 | formatDuration en $libs/days (o uix.format) |
$libs/days |
Regla A23: si es puro y reutilizable, va a la lib — nunca reimplementar dentro de soma | bajo |
| G-4 | sema/components/media-player.ts |
fix de una línea: 'provider' → 'play-button' |
trivial | |
| G-5 | H-10 — el commit se dispara en el clic, no cuando el resultado aterriza, así que se solapa con el contact-activate del Button y suenan dos earcons |
soma/…/media-player-provider.svelte.ts |
El libro (cap. 22 §8) prescribe la separación temporal y commit-complete ya cabalga el evento ended: es aplicar el patrón que el propio fichero usa, a togglePlay / toggleMute / seek |
bajo |
Waveform (D-AP.4) no está en esta tabla: no es un gap del player sino una
iniciativa hermana con su propia ruta de 9 fases.
8.5 F0 · verificaciones ejecutadas (2026-07-30)
La mitad de F0 que no necesita firma, hecha antes de pedirla — porque tres resultados cambian lo que hay que firmar.
| # | Qué se pidió verificar | Resultado |
|---|---|---|
| 1 | H-1, con stamp real | ✅ Confirmado. data-event="commit-toggle-play" estampa en <button data-media-player-play-button>; el selector del pack apunta al provider y no puede casar. Muerta 1 de 3 reglas |
| 2 | ¿Sonoriza el Slider compuesto al arrastrar? |
✅ Sí, por composición: TimeSlider → Slider.Provider → resolveSliderDragSound en cada handle-drag. Stamp medido sobre [data-slider] |
| 3 | ¿Se auto-limpia sustain-loading? |
⚠️ La pregunta era inválida: no se emite nunca (H-9). sustain-end queda descartado |
| 4 | Celdas ? de Segmentos |
⚠️ 1 de 6, no paridad. Sólo Vidstack; wavesurfer por plugin |
| 5 | Reproducir vidstack#1195 | ⚠️ No reproducible aquí (requiere iOS real). Caracterizado abajo |
H-9 · sustain-loading es superficie de contrato MUERTA
Declarado en el morfo (media-player.ts:176),
verificado por el test del morfo, documentado en el README de soma como
«Drives the spinner + sustain-loading» y jamás emitido: grep de
sustain-loading en todo el repo no encuentra ni un trigger. El spinner sí
funciona — lo mueve data-buffering, que es estado, no señal.
Es un evento declarado sin emisor. Antes de decidir sustain-end hay que
decidir qué hacer con él: cablearlo (el provider ya tiene waiting, así que
son dos líneas + el clear en canplay) o retirarlo del morfo. Va al gate
como parte de D-AP.10, no lo decido yo.
8.6 · H-10 — un botón compuesto suena DOS veces, y no es cosa del player
Empezó como una observación del stamp y acabó siendo una medición: un clic en el botón de play produce dos earcons, no dos estampas con una voz.
Por qué no lo colapsa el árbitro de dominancia. Los rangos son
occurrenceRank = evaluable×10 + activación:
| Señal | Familia | Intent | Rango |
|---|---|---|---|
contact-activate (del Button) |
contact → estructural |
ninguno | 0 |
commit-toggle-play (del player) |
commit → evaluable |
neutral |
10 |
La regla silencia a un recién llegado sólo si una ocurrencia ya activa lo
supera. Aquí llegan en orden 0 → 10: cuando entra el segundo, el activo (0) no
lo supera, así que pasa; y el primero ya había sonado. El modelo asume que la
señal de más rango llega antes o sola — el caso inverso, a microsegundos, no
está arbitrado. Y no hay pack de button que module contact-activate: usa
la base de familia (800 Hz, 60 ms, gain 0.25), que se solapa con el commit de
100 ms que entra encima.
CORRECCIÓN (misma sesión, tras leer el contrato del Button). Escribí que
esto era «el patrón de composición del catálogo» y que la decisión era del
canon. Es al revés: el canon ya lo prescribe, y es el player quien lo
incumple.
El provider del Button lo documenta citando el libro
(README:72-91, cap. 22 §8 y §10):
contact-activate es sequence: 'pre' y sólo acusa la recepción del gesto;
la consecuencia evaluativa la dispara el consumidor cuando el resultado
aterriza — «the celebration sound plays at the moment the save actually
resolves, not when the click is received». Es decir: dos sonidos separados en
el tiempo, con dos significados distintos. No hay solape en el diseño.
El solape lo produce el player: togglePlay() dispara commit-toggle-play
síncronamente dentro del handler del clic
(media-player-provider.svelte.ts:319),
no cuando el medio arranca. El resultado real llega después, en el evento play
/ pause del elemento — que el provider ya recibe (ahí mismo arranca el
bucle de progreso, y commit-complete ya cabalga ended con ese patrón
exacto).
Mismo defecto en toggleMute() (commit-toggle-mute) y en seek()
(commit-set-time). toggleFullscreen y togglePip sí lo hacen bien:
esperan la promesa de la plataforma antes de disparar.
Así que H-10 no es materia de canon ni del árbitro de dominancia: es un fix en el provider del player — mover el commit del clic al evento de resultado, que es donde el libro lo pone. Entra como G-5 en §8, y el árbitro se queda como está: nunca tuvo que arbitrar dos ocurrencias que el diseño no quería simultáneas.
Para D-AP.7 sigue en pie lo demás: ninguna de las dos señales vive en el provider, así que el silencio en modo audio tiene que cubrir descendientes.
8.7 · La postura de participación, resuelta — no hay abismo bajo el gate
La última pregunta de la sesión —¿es correcta expression: 'pack' para un
compuesto de Buttons y Sliders?— la responde la doctrina de
participación (sema.md §«When a component deserves a pack»)
sin tocar la premisa de D-AP.1:
| Evento del player | Veredicto |
|---|---|
commit-complete · commit-fail · shift-* · commit-toggle-captions · commit-toggle-play (el resultado; el Button sólo posee el contacto) |
Suyos — ningún hijo los declara. 'pack' correcto; el guard S11d además lo exige mientras exista sema/components/media-player.ts |
commit-set-time |
La única duplicación real: mismo suceso que el commit-set del Slider embebido. El rationale de radio-cards aplica literal → D-AP.13 |
| El timing de play/mute | No es de participación: es G-5 (el patrón correcto ya vive en commit-complete / ended) |
Y el propio provider ya practica la delegación correcta dos veces:
setVolume («the perceptual signal is the composed Slider's») y scrubTo
(scrub en vivo, sin señal). El player es un híbrido legítimo con una
duplicación y un defecto de timing — no un componente sobre premisas rotas.
Consecuencia para el gate: D-AP.1 queda intacta; se añade D-AP.13; nada más se reabre.
vidstack#1195, caracterizado
Abierto y sin respuesta del mantenedor, que pidió reproducción. iPhone 15 /
iOS 17.3.1, Chrome y Safari móviles; escritorio no afectado. El error es
webkit blob resource error 1 y desaparece quitando clipStartTime /
clipEndTime.
Lectura para nosotros: el modo de fallo apunta a fuentes blob: en iOS
combinadas con la forma en que Vidstack acota el rango seekable, no a la idea
de clip en sí. Nuestro diseño no toca la fuente ni el rango seekable —
acota en el provider (§7.5)—, así que probablemente no aplica. Pero probable
no es verificado: queda como riesgo abierto que exige un iOS real antes de
dar el clip por cerrado, y este entorno no puede darlo.
9. Fases (tras el gate)
| Fase | Contenido | Verify |
|---|---|---|
| F0 — Verificación y firma · verificaciones ✅ HECHAS (§8.5), firma PENDIENTE | Slider compuesto?sustain-loading?? de SegmentosDefaultControls, Slider provider y la recipe del scrubber ANTES de diseñar (regla feedback_study_real_recipes_before_building) · presentar §4 y §6 y firmar D-AP.1…D-AP.13 |
tabla de decisiones firmada en el chat; este plan actualizado |
| F1 — Gaps de framework | G-1 · G-2 (bloqueante) · G-3 · G-4, cada uno con su test | check 0 propios · vitest del slider verde · component:audit --only slider PASS · morfo:vocabulary exit 0 |
| F2 — Morfo | 6 partes + 1–2 eventos + texts + catálogo langs/components/media-player.ts · retirar commit-set-time (D-AP.13) y ejecutar lo firmado sobre sustain-loading (D-AP.10 / H-9) |
validateMorfo · test del morfo · morfo:vocabulary · translations:check |
| F3 — Soma | sub-providers de las partes nuevas · MediaSession opt-in (con guarda de singleton) · clip + proyección + clamp (§7.5) — sólo si D-AP.12(b) · formatTime→G-3 · tests del provider |
test del provider verde (incl., si D-AP.12(b): dominio del clip, commit-complete en end, silencio con loop) · check 0 propios |
| F4 — Sema | regla de silencio en audio (D-AP.7) + fix H-1; expression sigue 'pack' |
morfo:vocabulary (coherencia pack↔expression, guard S11d) · stamp verificado en navegador |
| F5 — Eidos | audio-layout.svelte + raíz <AudioPlayer> + recipe de las 4 variantes + la pista secundaria de G-2 pintando el buffer (la ventana del clip, si D-AP.12(b)) + tokens --audio-player-* en lib/recipes/base.ts |
generate:eidos-css (TSC) · recipe-css-contract · eidos-lint 0 inválidos · component-api-contract |
| F6 — Formas | responsive de variant/size; evaluar container queries (D-AP.9) |
prueba visual de las 4 variantes × light/dark × RTL × densidad, con captura y mirada (regla dura feedback_verify_visually_before_showing) |
| F7 — Demo + dossier + cierre | demo v2 de 9 pestañas (cada prop = control vivo, paridad de chips D-7.4) · README eidos (Baseline/Comparativa≥3/Decisiones/Gaps con disposición) · README soma actualizado · data-perm-step |
component:audit --only media-player PASS 0 errores · smoke · perm:check · check 0 propios · docs:check |
Método: no-cascada — una pieza, verificar, la siguiente (método canonizado
en _cierre.md). Gates completos al cerrar
cada fase.
10. Fuera de alcance
- Cola / playlist (D-AP.6) →
blockso app; se registra ennext-features.md. - Waveform (D-AP.4) → iniciativa hermana, ruta de 9 fases propia.
regions(n segmentos con estado propio) → descartado, no diferido: es el modelo de wavesurfer y trae su propio contrato (selección, arrastre, solapamiento).clip(D-AP.12) resuelve el caso real —reproducir un trozo— sin abrirlo.- Enganchar el player a
uix.sound.context(D-AP.11) → iniciativa hermana, conWaveformy el visualizador. El v1 no toca Web Audio. - Capítulos, transcripción, ganancia >100 %, ecualizador, grabación → v2,
cada uno con disposición escrita en el
## Gapsdel README. - Vendorizar motores (hls.js, Howler, wavesurfer) → el puerto
MediaProviderya es la respuesta; doctrina de cero dependencias. - Visualizador/espectro → si algún día se hace, su hogar es
$scene(EngineSceneya resuelve frame-loop, pausa fuera de vista, cap de DPR, reduced-motion obligatoria, pérdida de contexto y presupuesto) o el tierpacks— nunca un canvas suelto dentro del recipe del player.
11. Riesgos declarados
| Riesgo | Mitigación |
|---|---|
El morfo de media-player ya es el mayor del catálogo; crece más |
Todas las partes nuevas optional: true; si tras F2 el fichero pasa de ~700 líneas, reabrir D-AP.1(b) con datos, no con intuición |
| MediaSession es singleton de documento | Registro solo mientras suena + liberación en pause/dispose; documentado en el README como limitación conocida |
La variante bar vive sobre fondos arbitrarios |
data-depth + el layer de estado; nunca color crudo |
El clip depende de timeupdate (~4/s) |
El bucle es impreciso (~±125 ms) y se declara en el README; la precisión de muestra necesitaría Web Audio, que el v1 no engancha (D-AP.11) |
| Bug abierto de clip en Chrome/Safari móvil (vidstack#1195) | Reproducirlo en F0 antes de firmar D-AP.12; si se confirma, el clamp del provider necesita defensa propia y se escribe como limitación conocida |
| Sobrecarga de alcance | El v1 es la columna «implementar» de §2 y nada más; todo lo demás lleva disposición escrita |