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.
svelte-kit-vice/docs/process/PLAN-audio-player.md

108 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) — $sound existe 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: "Lee docs/process/PLAN-audio-player.md y 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ón fila 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.sound nunca toca el volumen del contenido · el silencio de UI debe alcanzar al Slider compuesto §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-loading es 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, no channels; criterio: incongruencia, no frecuencia) · D-AP.13 nueva: el scrubber duplica el contrato del Slider (§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 de component-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:

  1. 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».
  2. La identidad sustituye al fotograma. Carátula, título, artista/serie, capítulo. Ninguna existe hoy más allá de un Title de una línea.
  3. 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: md no 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 en recipe-css-contract.test.ts).
  • variant responsive — 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 DESCARTADO en F0 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-valuetext hablado en el scrubber (cierra H-4): «2 minutos 7 segundos de 41 minutos», no «127». Es gap de framework (§8).
  • Artwork con alt real; el resto de la identidad como texto real (nunca aria-label sobre divs vacíos).
  • commit-fail ya declara requiresLiveRegion + reducedMotionFallback: 'text' — se conserva.
  • Contraste: la barra bar sobre superficie arbitraria consume data-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

  1. prefs.sound NUNCA 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 es volume / muted del provider, y nada más.
  2. El silencio de UI en audio (D-AP.7) tiene que alcanzar al Slider compuesto — ✅ CONFIRMADO en F0. El TimeSlider compone Slider.Provider (media-player-time-slider.svelte:37), y el provider del slider adjunta resolveSliderDragSound(...) como override en cada handle-drag (slider-provider.svelte.ts:231). Stamp medido arrastrando el scrubber: handle-drag y commit-set sobre [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 adjunta resolveSliderDragSound (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 por channels: ['haptic'] (D-AP2.7 v2).
  3. Y no basta con el Slider (hallazgo nuevo de F0, H-10): el botón de play emite dos señales por clic — contact-activate del Button compuesto y commit-toggle-play del 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 Verificar ✅ / arreglar H-1 (selector del pack sema, confirmado con stamp) 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 Confirmar H-1 ✅ · ¿sonoriza el Slider compuesto? ✅ · ¿se auto-limpia sustain-loading? ✅ (no se emite — H-9) · celdas ? de Segmentos ✅ (1 de 6) · vidstack#1195 ⚠️ caracterizado, no reproducible sin un iOS real · queda: leer DefaultControls, 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) → blocks o app; se registra en next-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, con Waveform y 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 ## Gaps del README.
  • Vendorizar motores (hls.js, Howler, wavesurfer) → el puerto MediaProvider ya es la respuesta; doctrina de cero dependencias.
  • Visualizador/espectro → si algún día se hace, su hogar es $scene (EngineScene ya resuelve frame-loop, pausa fuera de vista, cap de DPR, reduced-motion obligatoria, pérdida de contexto y presupuesto) o el tier packs — 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

Powered by TurnKey Linux.