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

575 lines
108 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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`](./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`](./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`](./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`](../guides/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`](../../src/arts/sound/README.md), 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`](../../src/uix/morfo/components/media-player.ts) |
| soma | provider request-driven + **puerto `MediaProvider`** + adaptador nativo + 14 wrappers + test 9/9 | [`soma/components/media-player/`](../../src/uix/soma/components/media-player/) |
| sema | pack «voz de transporte contenida» (3 reglas) | [`sema/components/media-player.ts`](../../src/uix/sema/components/media-player.ts) |
| eidos | recipe _Lumière_ (11.7 KB) + 17 wrappers + `DefaultControls` | [`eidos/components/media-player/`](../../src/uix/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](https://vidstack.io/player/components/) ·
[Vidstack Default Layout](https://vidstack.io/docs/player/components/layouts/default-layout/) ·
[Media Chrome](https://github.com/muxinc/media-chrome) ·
[Plyr](https://github.com/sampotts/plyr) ·
[Red Hat audio player](https://ux.redhat.com/elements/audio-player/) ·
[wavesurfer.js](https://github.com/katspaugh/wavesurfer.js/) ·
[react-h5-audio-player](https://github.com/lhz516/react-h5-audio-player) ·
[MDN MediaSession](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession) ·
[web.dev Media Session](https://web.dev/articles/media-session) ·
[W3C WAI media players](https://www.w3.org/WAI/media/av/player/).
---
## 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](./PLAN-sound-engine.md)), 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](../../src/uix/soma/components/media-player/components/media-player-time-slider.svelte) + [provider:322](../../src/uix/soma/components/media-player/media-player-provider.svelte.ts) — 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 `div`s 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](../../src/uix/soma/components/media-player/components/media-player-time-slider.svelte)),
y el provider del slider adjunta `resolveSliderDragSound(...)` como override
**en cada `handle-drag`** ([slider-provider.svelte.ts:231](../../src/uix/soma/components/slider/slider-provider.svelte.ts)).
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](./AUDIT-docs-code-ledger.md)) 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`](../../src/uix/sema/chans/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](../../src/uix/morfo/components/media-player.ts)),
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](../../src/uix/soma/components/button/README.md), 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](../../src/uix/soma/components/media-player/media-player-provider.svelte.ts)),
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 `Button`s y `Slider`s?— la responde la **doctrina de
participación** ([`sema.md` §«When a component deserves a pack»](../architecture/sema.md))
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`](../audit/components/_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.