# CONTINUE — el media-player (nació del hilo RTL, que ya cerró) > **Kickoff**: _"Lee `docs/process/CONTINUE-player-rtl.md` y sigue por la cola de §0."_ > **Fecha**: 2026-08-01 · actualizado 2026-08-04 · Rama `alpha-0.1-dir-prefs` > (sale de `alpha-0.1-sec-dom`). > **§1, §2, §3 y §4 están CERRADAS y verificadas** — no las rehagas. El > documento arrancó siendo del eje RTL; hoy es el handoff del **componente**. > Empieza por **§0**, que es la cola priorizada; §1–§4 son el registro de lo > hecho, y se leen sólo si necesitas el porqué de una decisión. --- ## 0. La cola — por orden de valor (estado a 2026-08-04) ### Dónde está el componente `component:audit --only media-player` = **PASS** (0 errores; los 2 avisos son falsos positivos del script, que busca `MutationObserver` y `uix.events.emit` como TEXTO en la página y no los ve porque la demo los factoriza en `DemoTrace` y `SemaPanel` — comprobado en el código de ambos). | Guard | Estado | | ------------------------- | --------------------------------------------------------------------- | | `check` | **0 errores propios** (la base global fluctúa por las otras sesiones) | | vitest soma del player | **14/14** | | `eidos-lint media-player` | **invalid 0 · class-hooks 0** (morfo-backed 29) | | `morfo:check` | player sin fallos (7 preexistentes ajenos) | | `smoke` de la ruta | PASS | | `perm:check` | **SIN CORRER** — es el largo; el topbar dio paso a todas las rutas | **Partes declaradas sin implementar: 1 de 23.** Con `Captions` cerrado sólo queda **`settings-button`**. ### 0.1 · `settings-button` — el ítem grande restante Menú de velocidad / calidad / pista. Exige componer `DropdownMenu` a nivel soma; la mecánica ya existe (`setPlaybackRate`, `cycleRate`, y el `RateButton` los usa). **Cerrarlo pone el contrato del morfo en 23/23**, que es lo único que separa al componente de tenerlo completo. ### 0.2 · Waveform como scrubber — DESBLOQUEADO, faltan dos precisiones Las decisiones del usuario (§5) resolvieron el nudo. Antes de implementar hay que decidir —y **NO improvisar**— dos cosas: 1. Qué es exactamente «genérico»: ¿patrón fijo, pseudoaleatorio estable derivado de la fuente, o barras planas? 2. ¿Basta la ausencia de `peaks` como señal de streaming, o hace falta una prop aparte? ### 0.3 · Defectos vivos, medidos - **La variante `bar` se estruja a 720px**: el `time-slider` colapsa a **ancho 0** y el mute a **19px** (los demás miden 36). Pasa **igual en LTR y RTL**, así que no es del eje de dirección — la fila no tiene sitio para lo que mete. - **`audio-player` rojo en `eidos/lint.test.ts`**: la capa de variantes no tiene morfo propio y el guard no tiene categoría para «capa de variantes sobre morfo hermano». Es un **guard compartido**; decisión pendiente (¿categoría nueva o `KNOWN_MISSING_MORFO`?). No tocar en solitario. ### 0.4 · Sin verificar por ojos La matriz **4 variantes × claro/oscuro × RTL**. Cubierto a 2026-08-04: vídeo LTR/RTL, audio `card` en RTL, y los subtítulos en claro y oscuro. **Faltan `row` / `bar` / `inline` y casi todo el modo oscuro.** ### 0.5 · Diferidos con disposición escrita (no son deuda oculta) Doble-tap-seek · preview de miniatura en el scrubber —que es un **gap del `Slider`**, no del player— · migrar los botones a `renderProps`, que es un barrido global del catálogo. ### 0.6 · Ajeno al player, abierto por estas sesiones - **El interruptor Sound del topbar no silencia los earcons** (§4.2). Es de la app de demos, no del framework. - `src/arts/adom/__scratch-verify.ts`: fichero de sonda que dejó un agente, **sin trackear**, suma 1 error a `check`. Sobra; no se borró por la regla de no eliminar sin instrucción explícita. - Del hilo de dirección: `html lang` no sigue al idioma (el `dir` sí) y la deuda ajena de `CONTINUE-direction.md` §8.4. --- > El hilo de audio propiamente dicho sigue en > [`CONTINUE-sound-engine.md`](./CONTINUE-sound-engine.md); este documento > recoge lo que la **mirada del usuario** abrió el 2026-08-01 y quedó SIN cerrar. ## Lo que se cerró el 2026-08-01 (7 commits, verificados) | Commit | Qué | | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | | `df86a4e50` | El anillo del `commit` dejaba de enmarcar contenedores (slider + media-player) · saltar al final ya no anuncia compleción falsa | | `e096f65e3` | MediaSession verificada por el usuario (carátula + título + artista en el hub de Chrome) | | `905012507` | Waveform: el buffered vuelve a **banda recta** con acento lavado | | `58acb778e` | Waveform F6 firmada — iniciativa COMPLETA | | `77f8a4e46` | La demo del waveform carga solo la pista real; `sample` deja de fingir que es una prop | | `4356020e3` | La carátula de `card` sale a token (`--audio-player-artwork-size-card`) | | `3ceda5b59` | El botón de velocidad ya dice su velocidad (`emit: 'value'`) · el player reenvía `dir` a sus dos sliders | ## 1. ~~PRIORIDAD — el Slider está roto en RTL~~ · **CERRADO 2026-08-02** Cerrado en `013ceac57` + `5469e05df` (rama `alpha-0.1-dir-prefs`). **La hipótesis de este documento acertó las dos piezas visibles** — el Slider sí estampaba `dir` y `slider.css` sí mezclaba lógicas con físicas — pero eran síntomas, no la causa. Lo que resultó ser, medido: | | | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Del slider** | `slider.css` emparejaba `inset-inline-start: 50%` (lógico) con `translateX(-50%)` (FÍSICO, no se voltea) en las tres reglas verticales. Medido en Chrome: raíz/pulgar/ticks en x=961, raíl y relleno en x=955. El `Tick` se libraba porque ya usaba `margin-inline-start` — ése era el idioma correcto del propio fichero. Mismo defecto en `accordion.css` (`text-align: left`), el único físico que quedaba en todo eidos. | | **La causa raíz, que no estaba en las hipótesis** | `makeDimension().get()` (`arts/prefs`) leía `engine.snapshot()` esquivando la celda `$state`, así que `soma.prefs.getDir()` era una **lectura sin tracking** y los ~64 componentes que resuelven su dirección desde prefs la **congelaban al montarse**. La proyección DOM se salvaba porque usa `onChange`: misma dimensión, dos caminos de lectura, uno solo reactivo. | | **Y el estampado** | 33 de 37 componentes escribían `dir` con un valor SIEMPRE concreto → una app que ponga `` sin registrar la preferencia se encontraba 33 islas del revés. Ahora el atributo se omite cuando nadie afirmó nada (modelo de Zag, aplicado uniforme). | **No hizo falta elegir «un solo dueño de la inversión»**: el JS sigue calculando físico y el CSS sigue siendo lógico. No había doble volteo — había un transform físico donde tocaba uno lógico. ✅ **La contradicción del pulgar del volumen queda explicada**: con `getDir()` congelado, el mismo slider daba lecturas distintas según cuándo se midiera respecto al montaje. No era una sonda mintiendo. Guards que nacieron en rojo y ahora pasan: `arts/prefs/test/active-prefs-reactivity.svelte.test.ts` (intent · derivación · granularidad por clave) y `uix/active-uix/test/prefs-view.svelte.test.ts`. **De regalo**: elegir árabe voltea la dirección por derivación desde el idioma — la cadena que `active-architecture.md:407` documenta y que en esta app era código muerto, porque la shell fijaba intent desde el primer frame y no había ningún idioma RTL en el catálogo. ⚠️ Sigue abierto de este eje: **`html lang` no sigue al idioma** (el `dir` sí), y `perm:check` sin ejecutar — el topbar ya lleva `data-perm-step`, lo que hace que **toda** ruta tenga paso y la pasada sea mucho más larga. ## 2 y 3. ~~Disposición de los botones + iconos de seek~~ · **CERRADAS 2026-08-04** **Eran el mismo defecto visto desde dos lados.** Las sondas de aquel día acertaban y §2 no era un defecto de disposición: re-medido con el contexto verificado ANTES de cada número (`data-media` / `data-variant` / `data-dir` leídos tras cada clic), la geometría mirroriza **exacta** en las cinco disposiciones — vídeo con `DefaultControls` y audio en `card` · `row` · `bar` · `inline`. Cada `x` en RTL es el espejo del LTR ±1px, comprobado punto por punto. Lo que el ojo rechazaba eran los **glifos**, que no se volteaban (`transform: none`, `rotate: none`). En RTL el transporte se leía `▶▶ ▶ ◀◀`: las dos flechas exteriores apuntando **HACIA DENTRO**. Es literalmente el síntoma que `carousel.css:235` y §9.9 de [`CONTINUE-direction.md`](./CONTINUE-direction.md) ya habían documentado y arreglado en los chevrons del carousel. | | LTR (izq→der) | RTL antes | RTL ahora | | ------------------ | ------------- | ------------------ | ----------------- | | transporte (vídeo) | `▷ ◁◁ ▷▷` | `▷▷ ◁◁ ▷` ✗ dentro | `◁◁ ▷▷ ◁` ✓ fuera | | transporte (audio) | `◁◁ ▷ ▷▷` | `▷▷ ▷ ◁◁` ✗ dentro | `◁◁ ◁ ▷▷` ✓ fuera | **Alcance elegido por el usuario: todo lo direccional** — seek · play/pause · altavoz de volumen/mute · esquina del PiP. Fuera se quedan `fullscreen` (sus cuatro esquinas son simétricas) y **`captions`, que es la única llamada que el catálogo no ha hecho**: si el glifo de subtítulos cuenta como direccional está sin decidir, y se dejó SIN voltear. **El mecanismo**, heredado del precedente del carousel: - `scale: -1 1` en el recipe, no `transform` — la propiedad independiente COMPONE con lo que un icono escriba inline en vez de reemplazarlo. - **`scale`, no `rotate: 180deg`**: sólo coinciden en glifos verticalmente simétricos. Rotar el icono del PiP le llevaría el recuadro a la esquina superior izquierda en vez de a la inferior izquierda. - Anclado a `[data-media-player][data-dir='rtl']` — el atributo PROPIO del componente, no el `dir` del DOM, así que no es el `[dir='rtl']` prohibido y garantiza que el glifo coincida con la matemática del teclado, que resuelve de esa misma fuente. `eidos-lint media-player`: **invalid 0 · class-hooks 0**. De paso: el `--slider-range-bg` del scrubber era un `linear-gradient(90deg)` FÍSICO, así que en RTL la rampa corría al revés respecto al sentido en el que crece el rango. Par `:dir()` con `270deg`, como la rampa de saturación del `color-picker`. ### 2.1 · El `dir` del player estaba PARTIDO POR LA MITAD — encontrado por el camino `` sin `dir` ambiental dejaba el cromo dispuesto en **LTR** y sus dos Sliders en **RTL**. El wrapper pasaba la prop CRUDA (`readableActive(() => dir)`) y el provider sólo la reenviaba a los sliders: no resolvía la cadena ni estampaba nada. En la demo estaba **enmascarado** porque el stage estampa `dir` además de pasar la prop — por eso ninguna medida anterior lo vio. ⚠️ **El censo de canonización de `CONTINUE-direction.md` §9.15 no lo tenía**: el media-player no es ni canónico, ni grupo A, ni grupo B. Era un **cuarto caso** — expone `dir`, pero no lo resuelve NI lo estampa. Si alguien vuelve a auditar la canonización, ese censo cuenta wrappers y providers, no componentes que simplemente reenvían la prop a un hijo. Canonizado con el patrón de §9.16: `activeDir(() => dir, soma)` en el wrapper · `resolvedDir` en el provider · `dir` crudo + `data-dir` resuelto en los props. El tipo pasó de `dir?: Direction` a `dir: Direction | undefined` (la clave siempre la pone el wrapper; sólo el VALOR puede faltar) — ensanchar el tipo primero, que es lo que destapó los 11 puntos del test. ### 2.2 · El teclado seguía siendo físico El morfo declaraba `ArrowLeft→seek-backward` / `ArrowRight→seek-forward`, ciego a la dirección, mientras soma ya tiene `getDirectionalKeys(dir, orientation)` justo para esto y el Slider embebido sí es direccional. **Decisión del usuario: direccional.** Ahora resuelve por ese helper. `j` / `l` se quedan quietas: son alias semánticos, no flechas. ⚠️ **CORRECCIÓN (2026-08-04, misma jornada)**: la tabla de abajo se leyó mal. Que la raíz y el pulgar dieran el mismo ±10 **NO significaba que estuvieran de acuerdo** — significaba que el `seekBy` del player **pisaba** el paso más pequeño del Slider, porque ambos escriben `currentTime`. Las dos manos actuaban sobre la misma tecla. Se destapó midiendo con magnitudes distintas (`ArrowUp` sobre el pulgar del scrubber movía el tiempo **y** el volumen) y está arreglado: el player exime ahora las teclas que nacen dentro de un `[data-slider]`. **Lección**: dos manejadores que escriben la MISMA magnitud son indistinguibles; para detectar doble manejo hay que cruzar ejes. Medido, un solo `seekStep` por pulsación: | | LTR | RTL | | ----------------------------------------- | --- | ------- | | `ArrowRight` (raíz / pulgar del scrubber) | +10 | **−10** | | `ArrowLeft` (raíz / pulgar del scrubber) | −10 | **+10** | ### 2.3 · Verificación `check` **74 = línea base** · `rtl:check` 1 (el de `palabras`, preexistente) · `morfo:check` 7 fallos, ninguno del player (preexistentes) · `eidos-lint media-player` invalid 0 · vitest del player + los dos guards de dirección **16/16**. A ojo en el Chrome real del usuario, A/B completo en LTR y RTL, vídeo y audio/card. ## 4. ~~Medición del escenario mixto~~ · **CERRADA 2026-08-04** **La receta que este documento prescribía no hacía falta.** «Instrumentar antes del boot + navegación client-side» era la respuesta a la pregunta equivocada. El problema real es que se parcheaba el **CONSTRUCTOR**, que sólo puede responder «¿se creó uno nuevo?». La solución es parchear el **PROTOTIPO**: > Cualquier contexto —nacido cuando sea— tiene que llamar a `createGain` / > `createOscillator` / `resume` para hacer algo. Recogiendo el `this` de esas > llamadas en un `Set` se enumeran las INSTANCIAS, no las creaciones, y un > contexto preexistente aparece igual. Sin inyección pre-boot y sin tocar > `app.html`. Con eso, medido en el Chrome del usuario sobre `/uix/components/media-player`, audio `card`, pista real de 6:12 **sonando sin mutear a volumen 1**: | | | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Contextos distintos que trabajaron | **1** (`running`, 44,1 kHz) | | Earcons disparados encima | **26** (52 osciladores + 78 gains + 26 biquads — el par de osciladores + biquad lowpass que describe `PLAN-sound-engine.md` §4) | | `createMediaElementSource` | **0** — la media NUNCA entra en el grafo | | Contextos cerrados / churn | 0 / 0 (contraste con el «antes» de §15.2: 10 creados, 9 cerrados) | **La invariante de un solo `AudioContext` por documento se sostiene en el escenario mixto.** ### 4.1 · Y el contexto preexistente, por fin fechado Es real y la instrumentación vieja no podía verlo. Con la sonda instalada en t=10,9s y el primer earcon a t≈30s, el reloj del contexto marcaba 25,86s → **el contexto nació en t ≈ 4,1s, sin ningún gesto de usuario, y ya `running`**. Dos señales independientes lo confirman: `constructed: 0` (el parche del constructor no lo vio nacer) y **la primera aparición es `createOscillator`, no `createGain`** — los tres `createGain` que `createContext()` hace de inmediato para el master y los dos buses ya habían pasado. ⚠️ **Mi primera hipótesis —que lo creaba el registro de `sound.media(el)` del player— es FALSA**, y el control la tumbó: en `/uix/components/button`, una página **sin ningún elemento media**, el contexto nace en t ≈ 4,08s, lo mismo. Es del arranque de la app, no del reproductor. Auditoría de código en paralelo (29 agentes, cada afirmación refutada adversarialmente) que respalda la lectura del navegador: - Sitio de construcción **único**: `arts/sound/engine-sound.ts:452`, y el ctor se busca en `globalThis` **en cada llamada, nunca cacheado a nivel de módulo** — por eso `constructed: 0` significa de verdad «nació antes del parche» y no «se coló por una referencia capturada». - Embudo único `createContext()` con tres llamadores (`prime` · `getOrCreateContext` · `ensureContext`); el motor no toca Web Audio hasta uno de ellos. - El player **estructuralmente no puede** meter la media en el grafo: guarda el handle tipado como `MediaProvider`, puerto que **no declara `attach()`**. Es más fuerte que «es opt-in y nadie lo activa». ### 4.2 · Hallazgo NUEVO y ajeno: el interruptor de Sound del topbar no silencia Salió de la auditoría y lo verifiqué midiendo. Con `data-sound="reduce"` (interruptor OFF, `soundOn: false` persistido) un clic en un `Button` sigue construyendo **2 osciladores + 3 gains**, y la envolvente es **idéntica**: ``` Sound ON [0, 0.25, 0.25, 0.0001] Sound OFF [0, 0.25, 0.25, 0.0001] ← mismo pico, mismos nodos ``` El toggle escribe el intent en prefs y estampa `data-sound` en ``, pero el root de composición (`web/routes/uix/+layout@.svelte`) **no pasa `preferences`** al canal de sonido de sema, que cae a nivel `'full'`. El framework sí lo soporta (`sema/engine.ts` reenvía `soundOptions.preferences ?? opts.preferences`); es la app de demos la que no lo cablea. ⚠️ **Alcance de lo que medí**: la envolvente del PROPIO earcon. No medí una ganancia de bus aguas abajo, así que no puedo afirmar «se oye igual de fuerte» — eso lo zanja el oído. Dos líneas independientes (medida + auditoría) apuntan a que el interruptor es inerte. **Es de la app de demos, no del player**, y no se tocó. ## 4.bis · Ajeno al eje, encontrado midiendo — la variante `bar` se estruja A 720px de ancho y `size='md'`, la variante `bar` deja el `time-slider` en **ancho 0** y el botón de mute en **19px** (los otros miden 36). Pasa **igual en LTR y en RTL**, así que no es del eje de dirección: la fila no tiene sitio para todo lo que mete. Sin tocar. ## 5. Iniciativas dormidas (cada una con su gate) - ~~**`Captions`**~~ — **CERRADA 2026-08-04**. Se implementó tal como el diagnóstico predecía: `mode='hidden'` + pintar `activeCues` en la parte declarada. El espejo de una sola dirección de `captionsOn` también cerró: el estado se **deriva** de los modos reales y una pista que el SO encienda se adopta (degradada a `hidden`, para que no pinte encima). Detalle y las tres trampas de especificación que impuso —borrado imperativo, siembra del primer pintado, `activeCues` nulo en `disabled`— en el README de eidos, §Captions. Decisión declarada: el marcado inline se conserva, la metadata de posición **se descarta**. - **Waveform como scrubber del player** — cero cables hoy entre ambos, y es deliberado (D-AP2.4 + `PLAN-waveform.md` §0 lo aplazan a la v2 del player). El nudo NO era cambiar el componente: era **de dónde salen los picos**. El contrato del waveform (D-WF.2) dice que los provee la app, y un player _streamea_, así que no puede decodificar su fuente sin bajársela entera. Verificado además que `fetch` de `media.w3.org` y `soundhelix.com` **falla por CORS** mientras el `