|
|
# 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): `<MediaPlayer.AudioLayout/>` (espejo de `DefaultControls`, leído) + raíz competidora `<AudioPlayer>` (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`) |
|
|
|
|
|
|
## 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 `<AudioPlayer>`** 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 |
|