# PLAN v2 — Reproductor de sonido, SOBRE el servicio `$sound` > **Tipo**: plan de creación por fases (process — efímero, NO fuente de verdad). > **Fecha**: 2026-07-31 · **Estado**: **✅ GATE FIRMADO 2026-07-31** («ok, continua» > tras la presentación única, tras dos preguntas de diligencia sobre el estado y > el API del engine): **las 13 con las recomendaciones de la tabla** — incluidas > las sub-decisiones **H-9 = CABLEARLO** (D-AP2.10) y **D-AP2.7 = solo > `duckUiWhileContent` en v1**. F1 en curso. > Sustituye a [`PLAN-audio-player.md`](./PLAN-audio-player.md) (gate v1 rechazado > de raíz el 2026-07-31 por el SUSTRATO de sonido, hoy rediseñado y auditado: > [`PLAN-sound-redesign.md`](./PLAN-sound-redesign.md)). **Los HECHOS del v1 > siguen válidos y NO se re-excavan**: la matriz comparativa (§2 v1, verificada > 2026-07-30, segmentos = 1 de 6), los hallazgos H-1…H-10, las verificaciones > de F0 (§8.5–§8.7 v1), el contrato propuesto (§7 v1) y las formas (§6 v1). > Este plan re-presenta las decisiones ancladas al servicio; el razonamiento > largo vive en el v1 y aquí se referencia, no se copia. > **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-audio-player-v2.md`; > si el gate está firmado, continúa por la fase abierta."* > > **Precondición de lecturas (hechas 2026-07-31)**: provider soma completo · > morfo `media-player` · morfo `slider` · `DefaultControls` · puerto > `MediaProvider`. Quedan para ABRIR F1 (donde muerden): `slider-provider` > completo + recipe del scrubber (`feedback_study_real_recipes_before_building`). --- ## 0. Qué cambió el sustrato — decisiones v1 que se DISUELVEN | v1 | Qué pasó | | --- | --- | | D-AP.5 (MediaSession, riesgo singleton) | **Del servicio** (D-SR.6 firmada): el player pasa `metadata` y no toca `navigator.mediaSession`. El riesgo de dos players pisándose lo arbitra el servicio (fuente activa + promoción) | | D-AP.11 (¿el player consume `$sound`?) | **Disuelta** (D-SR.4 firmada): el transporte ES el handle del servicio (`uix.sound.media(el)`, forma exacta del puerto `MediaProvider` — drift-guard ya en verde). `attach()` existe y NO se usa en v1 | | D-AP.7 (silencio sema en audio, ¿quién restaura el master?) | El dueño existe: **`duckUiWhileContent`** (bus `ui` con retención refcount). Queda UNA decisión de alcance → D-AP2.7 | | §7.6 invariantes (prefs vs volumen de la obra; silencio alcanza descendientes) | **Estructurales**: el volumen de la obra es de la fuente/`content`; el duck del bus `ui` cubre TODOS los earcons (descendientes incluidos) sin reglas de cascada | ## 1. La migración (el trabajo nuevo del v2) 1. **`registerMedia` cambia su default**: `createProvider ?? (el => this.soma.uix?.sound ? this.soma.uix.sound.media(el, { metadata }) : nativeMediaProvider(el))` — el port `createProvider` SE CONSERVA (hls.js/dash siguen enchufables); sin `uix` degrada al adaptador local. 2. **El adaptador nativo de soma SE RETIRA** al completar la migración (sin shim, doctrina de la casa) y con él el drift-guard transicional (`media-provider-drift.test.ts`) — su trabajo habrá terminado. Un adaptador custom sobre elemento delega en `uix.sound.media(el)`; un embed sin elemento (YouTube) queda **sin ciudadanía** (limitación declarada en README). 3. **Prop nueva `metadata`** (`SoundMediaMetadata`) en el Provider — la identidad que Artwork/Title/Artist pintan es la MISMA que se proyecta al SO. 4. **G-5 aquí es cirugía concreta** (leído el provider): mover `trigger('commit-toggle-play')` de `togglePlay()` al evento `play`/`pause` de `applySnapshot` (el patrón que `commit-complete` ya usa con `ended`); ídem `toggleMute` → `volumechange`. 5. Los **pendientes del hilo del servicio** aterrizan aquí: medición Chrome del escenario mixto (earcon + media, 1 contexto) y prueba de teclas de medios — F7. ## 2. Decisiones — gate D-AP2 (SIN FIRMAR) | ID | Cuestión | Recomendación | | --- | --- | --- | | **D-AP2.1** | Pertenencia | = v1 D-AP.1(a): un motor, un contrato — `media-player` gana partes audio-only `optional` + layout eidos (razonamiento v1 §4) | | **D-AP2.2** | Puerta pública | = v1 D-AP.2(c): `` (espejo de `DefaultControls`, leído) + raíz competidora `` (precedente Toast/Toaster) | | **D-AP2.3** | Formas | = v1 D-AP.3(c): `variant: card·row·bar·inline` + `size` canónico, ambos responsive (detalle §6 v1) | | **D-AP2.4** | Waveform | = v1 D-AP.4(a): componente canónico, iniciativa hermana POST-v1 (`peaks` de la app; `$libs/plots`) | | **D-AP2.5** | Identidad → SO | **Nueva forma de D-AP.5**: prop `metadata` reenviada a `sound.media(el, { metadata })`; opt-in como siempre; el player NUNCA toca MediaSession | | **D-AP2.6** | Cola / playlist | = v1 D-AP.6(b): fuera (blocks/app; `next-features.md`) | | **D-AP2.7 v2** — REVISADA Y FIRMADA 2026-07-31 («lo firmo») | Semántica de los botones del player | **El transporte del player es SILENCIOSO POR DEFECTO, por definición** (D.7: su output ES audio; un tick de UI compite con la obra). Pack con el patrón D.5 (resta exacta del gain de familia — las primitivas del intent intactas, `threat`/`fulfill` afloran): `commit-toggle-play`/`-mute`/`commit-set-rate` en sus botones + **reglas de descendientes** (H-10) para el `contact-activate` de los Buttons compuestos (tuning nuevo `contact.silent`, −0.25) y el `commit-set` de los Sliders compuestos (scrubber y volumen). **Intactos**: `commit-fail` (risk debe oírse) y `commit-complete` (informa de la obra; revisitar con escucha real). **AMPLIADA por escucha real (mismo día, «los botones ya no… pero los sliders sí»)**: `handle-pick`/`handle-drag` de los Sliders compuestos → **silencio por CANAL** (`channels: ['haptic']`, reglas de descendientes de mayor especificidad) — ambos son ESTRUCTURALES sin intent (el libro concentra la evaluación en el drop, ya cubierto por la resta de `commit-set`), así que tirar su canal sonoro no borra carga evaluativa; el resolver del drag posee las primitivas del sonido, pero una señal con el canal inactivo no suena. Mi nota anterior («no callable desde el pack») era conservadora de más — corregida. Reactivación de la voz = capa de app (cascada 5b), sin prop nueva. Complementario con `duckUiWhileContent` (el pack calla AL PLAYER; el duck atenúa al RESTO de la app). Háptica se queda (no compite con la obra). Verificado: selectores 100% del builder tipado (solo el combinador es literal) · vocabulario limpio · sema 192/192 · check 0 propios | | **D-AP2.8** | Capítulos + transcripción | = v1 (b): v2 con disposición; capítulos dependen de G-2 | | **D-AP2.9** | Responsive | = v1 (a): `ResponsiveProp` en v1; container queries evaluadas en F6 | | **D-AP2.10** | Alcance v1 | Paridad (§2 v1) + superaciones vía servicio (metadata→SO · duck) · **sub-decisión H-9**: `sustain-loading` (evento declarado JAMÁS emitido) — **cablearlo** (2 líneas: `waiting`→emit, clear en `canplay`; recomendación, el spinner ya existe y el morfo lo promete) o retirarlo del morfo | | **D-AP2.11** | Providers custom | **Nueva**: el port `createProvider` se conserva; adaptadores sobre elemento delegan en el servicio; embeds sin elemento = sin ciudadanía (declarado). El adaptador nativo local se retira | | **D-AP2.12** | Segmentos (`clip`) | = v1 D-AP.12(a): v2, diseño CONGELADO en v1 §7.5; (b) sigue en pie solo si hay caso de uso hoy | | **D-AP2.13** | Scrubber duplica `commit-set` | = v1 D-AP.13(b): retirar `commit-set-time` del morfo y delegar en el Slider embebido (el provider ya practica el patrón en `setVolume`) | > ⚠️ **2026-08-12**: la frase de D-AP2.7 «el resolver del drag posee las > primitivas del sonido» quedó superada el 2026-08-06 (gestos por REPETICIÓN; > resolvers RETIRADOS el 2026-08-12, > [`AUDIT-docs-code-ledger.md` §D10](./AUDIT-docs-code-ledger.md)). El > mecanismo del silencio — `channels: ['haptic']` por descendientes — no > cambia. ## 3. Fases (tras la firma) | Fase | Contenido | Verify | | --- | --- | --- | | **F0 — Firma** | Presentar este gate UNA vez | Tabla firmada en el chat; plan actualizado | | **F1 — Gaps de framework** ✅ **COMPLETA 2026-07-31 (G-4 · G-3 · G-1 · G-2)** — G-2: parte `SecondaryRange` en el morfo + `SliderSecondaryRangeProvider` (espejo de Range: inline `left/width%` desde el inicio, RTL/vertical; 0% sin valor) + prop `secondaryValue?` + `Slider.SecondaryRange` en soma/eidos + regla CSS bajo la de range + token `--slider-secondary-bg` (28% del primario, entre pista y range) regenerado. ⚠️ Lección: claves nuevas de `ActiveProps` OPCIONALES (`?`) — requeridas rompieron a los 7 consumidores de `SliderProvider.create` (color/time/time-range-picker), corregido. Verificado: slider 11/11 · `component:audit --only slider` **PASS** · eidos-lint morfo-backed · `check` 74 con **0 propios** (incl. consumidores) | Lecturas hechas: `slider-provider` completo · regla `[data-slider-range]` de la recipe · wrappers soma/eidos · **G-4 ✅** selector del pack a `play-button` (declarado: play/pause pasa a sonar con el tap sutil del pack, antes regla muerta → family default; `morfo:vocabulary` limpio, sema 192/192) · **G-3 ✅** `formatDuration` en `$libs/days/format.ts` + tests (19/19; el consumo del provider llega en F3; dígitos no localizados aquí — disposición: `$format` encima) · **G-1 ✅** `aria-valuetext` (morfo Thumb `propRef('valueText')` condicional + prop `valueText?: (value, index) => string` en soma/eidos + source por-thumb; slider 10/10) · **G-2 (bloqueante, siguiente)** — DISEÑO listo tras las lecturas: espejo EXACTO de `Range` (estilo inline del provider: `left/width%` desde min hasta `secondaryValue`, RTL/vertical como `rangeStyle`) · prop `secondaryValue?: number` (unidades del dominio; extensión a par `[start,end]` DIFERIDA a clip v2, no especular) · parte morfo `SecondaryRange` optional (data como `Range`) · provider `SliderSecondaryRangeProvider` + componente soma + export `Slider.SecondaryRange` + wrapper eidos + regla `[data-slider-secondary-range]` (bajo la de range, `background: var(--slider-secondary-bg)`) + token en la sección slider de `lib/recipes/base.ts` · tests (estilo %, RTL, ausencia sin prop) | `check`: **80 errores repo (73 baseline + 7 AJENOS nuevos de la rama viva), 0 propios** · vitest slider 10/10 · sema 192/192 · days 19/19 · `morfo:vocabulary` solo warn ajeno (`cropper`) · queda al cerrar G-2: `component:audit --only slider` PASS | | **F2 — Morfo** ✅ **COMPLETA 2026-07-31** | 6 partes audio-only `optional` (artwork sin aria-hidden — alt real del consumidor · artist · identity · transport · rate-button con `data-rate` · live-indicator decorativo) · `commit-set-rate` (target rate-button; **el commit cabalga `ratechange`** — nace con el patrón G-5, cada fuente señala una vez al aterrizar el resultado) · **`commit-set-time` RETIRADO** (morfo + pin del test + trigger de `seek()`, rationale D-AP2.13 escrito en ambos) · **`sustain-loading` CABLEADO** (`waiting`→trigger; stateBound limpia con `data-buffering`) · teclado declarado E implementado (`j`/`l` alias seek · `<`/`>` ciclan `RATE_PRESETS` [0.5…2] desde el más cercano · `0-9` salto a % solo con duration finita) · texts `rate`/`live` | ✅ morfo 8/8 + player soma 18/18 · `morfo:vocabulary` limpio (warn `cropper` ajeno) · `check` 74 = baseline, **0 propios** · ⚠️ **`translations:check` BLOQUEADO POR AJENO**: el script muere evaluando `langs/components/palabras.ts` (catálogo con `url:` https — su stripper de comentarios cercena el `//`; palabras es territorio excluido y no se toca). Mis claves siguen el patrón idlangref de las 18 hermanas; correr el verify cuando el ajeno se arregle | | **F3 — Soma: LA MIGRACIÓN** ✅ **COMPLETA 2026-07-31** | Default del port → `uix.sound.media(el, { metadata })` (sin `uix` → sin engine; el port es la costura) · prop `metadata` en opts/types/wrapper (Active OPCIONAL) · **`nativeMediaProvider` RETIRADO** (media-provider.ts = contrato puro del puerto; exports/README al día; drift-guard borrado) · **G-5**: commits de play/mute cabalgan `play`/`pause` (saltando el pause de `ended`) y `volumechange` con transición de muted · `formatTime` → `formatDuration` · **6 sub-providers + 6 wrappers + exports** (artwork·artist·identity·transport·rate-button con `cycleRate(1)`+`data-rate`+label `rate`·live-indicator con `data-live` soma-owned) · tablas del README (props con `metadata`, 6 partes) | ✅ player 10/10 + morfo 8/8 (pin nuevo: default con motor REAL refleja `ratechange`) · prettier limpio · `check` 74 = baseline viva, **0 propios** | | **F4 — Sema** ✅ **COMPLETA 2026-07-31** | G-4 cayó en F1 · el docblock del pack escribe la doctrina D-AP2.7 (el silencio lo posee el SERVICIO: receta `defineEngineSound({duckUiWhileContent})`; la resta-de-gain queda diseñada sin cablear a propósito) · `expression: 'pack'` intacta | `morfo:vocabulary` limpio (S11d) · stamp en navegador → F7 (este entorno no alcanza el server vivo) | | **F5 — Eidos** ✅ **COMPLETA 2026-07-31** | **Buffer real** (G-2 consumido: `secondaryValue` + `Slider.SecondaryRange`; `.mp-buffered`/`--media-buffered`/`--_mp-rail` retirados; look intacto 42%→`--slider-secondary-bg`) · **6 wrappers eidos** (Artwork = AspectRatio+Image del catálogo vía `child`; RateButton = Button ghost con `{data-rate}×`; resto passthrough) · **`AudioLayout`** (espejo de DefaultControls: Identity[Artwork·Title·Artist·Live] + Transport[seek·play·seek] + Time·TimeSlider·Time + Rate·Mute·Volume) · **raíz competidora ``** en `eidos/components/audio-player/` (Toast/Toaster; `metadata` DERIVADA de title/artist/artwork salvo override — la identidad que ves ES la del lock screen) · **recipe de 4 variantes** scoped a `[data-variant]` (componer a mano queda sin opinar) + tokens `--audio-player-*` (gap·artwork-radius·artwork-size-row/bar) regenerados | ✅ `recipe-css-contract` + `component-api-contract` **37/37** · `eidos-lint media-player` 0 inválidos (audio-player.css = recipe de composición sobre el morfo del player; lint per-component no la cubre — gap del tooling declarado, F7) · `check` 78 = baseline+4 AJENOS nuevos (`arts/connection`, otra sesión), **0 propios** · Notas: badge `LIVE` hardcodeado en el wrapper (el text `live` del morfo queda para cablear con langs en F7) · los internos `--_mp-*` los comparte la recipe hermana del MISMO morfo (decisión declarada; promover a públicos si se quiere estricto) | | **F6 — Formas** | Responsive `variant`/`size` · evaluar container (D-AP2.9) | 4 variantes × light/dark × RTL × densidad **con captura y mirada** | | **F7 — Demo + cierre** | Demo v2 (cada prop = control vivo) con `duckUiWhileContent` activo y `metadata` real · README eidos (Baseline/Comparativa/Decisiones/Gaps) · **medición Chrome: 1 contexto con earcon+media** · **prueba de teclas de medios / pantalla de bloqueo (usuario)** | `component:audit --only media-player` PASS 0 · `smoke` · `perm:check` · `check` · `docs:check` | Método no-cascada; gates completos por fase; sin commits salvo petición. ## 3.4 Verifies `smoke` + `perm:check` de F7 (2026-07-31) `smoke`: **301/305 rutas PASS — todas las del hilo pasan** (media-player incluida). Los 4 fallos son rutas AJENAS al hilo: `/demos/ethereal`, `/demos/heroscrolling`, `/demos/dome-gallery`, `/uix/components/card` — hipótesis razonada (no diagnóstico): el árbol compartido lleva vivos cambios de otra sesión en `eidos/components/{heading,text}/types.ts` + `lib/types.ts` (tipografía) que Card consume. `perm:check`: 2 demos ajenas fallan (combobox, stepper; 6/126 pasos); la demo del player no tiene `data-perm-step` aún — opt-in pendiente anotado para el cierre del dossier. Nada del hilo en rojo; no se toca lo ajeno. ## 3.5 Auditoría de F1–F3 (2026-07-31, previa al commit) Clean-room sobre lo ejecutado; 2 hallazgos, ambos resueltos en el pase: - **AU-P.1 (real, arreglado)**: el salto por dígitos (`0-9`) interceptaba `Ctrl+1` y demás atajos del navegador — los hotkeys del provider no comprobaban modificadores (defecto preexistente que mi adición agravaba). Guard global: `ctrl/meta/alt` → los hotkeys no actúan (convención del sector). - **AU-P.2 (limitación, documentada)**: `metadata` se captura al registrar el elemento; un cambio en caliente no re-proyecta al SO. Escrito en la tabla de props del README con su disposición (pista nueva = `src`/remount; colas fuera por D-AP2.6; si un consumidor real lo pide, el servicio ganará `updateMetadata` en su propia iniciativa). Validación del conjunto: **45 suites / 458 tests verdes** (sound · sema · active-uix · active-app · morfo · slider · media-player · days) · `check` 74 = baseline viva, 0 propios · `docs:check` 0 · prettier limpio en lo propio (4 ficheros del slider viejos quedan fuera de norma y NO se tocan — ajenos). ## 4. Riesgos | Riesgo | Acotación | | --- | --- | | Doble transporte durante F3 | Swap atómico en el mismo pase: default nuevo + retirada del adaptador + tests actualizados juntos | | Consumidores de `nativeMediaProvider` | Solo la demo y el propio soma (verificar con grep al abrir F3); no hay consumidores externos | | El morfo crece (ya es el mayor) | Partes nuevas `optional`; si pasa de ~700 líneas tras F2, reabrir D-AP2.1(b) con datos | | `duckUiWhileContent` atenúa TODA la app | Es la lectura de la incongruencia (D.7) y es opt-in de composición; si la escucha pide precisión selectiva, la variante (b) de D-AP2.7 está diseñada (v1) y no bloquea | | Los de v1 §11 no disueltos (bar sobre fondos, clip/timeupdate…) | Siguen escritos en v1; aplican a sus fases |