|
|
|
|
@ -0,0 +1,377 @@
|
|
|
|
|
# PLAN — Reproductor de sonido (`AudioPlayer`)
|
|
|
|
|
|
|
|
|
|
> **Tipo**: plan de creación por fases (process — efímero, NO fuente de verdad).
|
|
|
|
|
> **Fecha**: 2026-07-30 · **Estado**: **⏸️ APARCADO 2026-07-30 — bloqueado por
|
|
|
|
|
> [`PLAN-sound-engine.md`](./PLAN-sound-engine.md)**. Decisiones D-AP.1…D-AP.10
|
|
|
|
|
> SIN FIRMAR. **No arrancar F0 hasta que el art `$sound` exista** (su F5 desbloquea
|
|
|
|
|
> éste).
|
|
|
|
|
> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-sound-engine.md` primero;
|
|
|
|
|
> este plan está aparcado detrás de él."*
|
|
|
|
|
>
|
|
|
|
|
> ### ⚠️ Correcciones pendientes de incorporar (sesión 2026-07-30, posteriores al cuerpo)
|
|
|
|
|
>
|
|
|
|
|
> El cuerpo de abajo NO las refleja todavía. Al desaparcar, incorporarlas ANTES
|
|
|
|
|
> de presentar nada:
|
|
|
|
|
>
|
|
|
|
|
> 1. **Faltaba la reproducción de SEGMENTOS entera.** No está en la matriz, ni en
|
|
|
|
|
> el contrato, ni en las variantes. Y **Vidstack SÍ los tiene**
|
|
|
|
|
> (`clipStartTime` / `clipEndTime`, con los que calcula el rango *seekable*),
|
|
|
|
|
> así que es **paridad pendiente, no superación**. Diseño acordado: `clip =
|
|
|
|
|
> { start, end, loop? }` en el provider · 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 ·
|
|
|
|
|
> `commit-complete` dispara en el fin del clip (mismo evento, frontera movida)
|
|
|
|
|
> · **con `loop` NO hay `commit-complete`** (un bucle esperado no es ocurrencia
|
|
|
|
|
> — regla B.5) · limitación declarada: `timeupdate` ~4/s ⇒ punto de bucle
|
|
|
|
|
> impreciso sin Web Audio · riesgo a probar: bug abierto de clip en Chrome/
|
|
|
|
|
> Safari móvil (vidstack#1195). `chapters` = v2 · `regions` = descartado.
|
|
|
|
|
> 2. **G-2 (pista secundaria del `Slider`) sube a BLOQUEANTE de v1**: ya tiene
|
|
|
|
|
> tres consumidores — buffer + ventana de clip + capítulos.
|
|
|
|
|
> 3. **D-AP.11 queda obsoleta y se reescribe contra el art.** Su premisa («no hay
|
|
|
|
|
> motor, ¿dónde lo ponemos?») era falsa: el ecosistema ya tenía un motor Web
|
|
|
|
|
> Audio de 407 líneas dentro de `sema/chans/sound.ts`. Cuando este plan se
|
|
|
|
|
> desaparque, `$sound` ya existirá y el player solo declara si lo consume.
|
|
|
|
|
> 4. **Dos invariantes a escribir en el contrato**: (a) **`prefs.sound` nunca toca
|
|
|
|
|
> el volumen del contenido** — gobierna el sonido de UI (sema); un reproductor
|
|
|
|
|
> de sonido es el primer componente del catálogo donde «sonido» significa dos
|
|
|
|
|
> cosas. (b) El silencio de UI en audio (D-AP.7) debe alcanzar al **`Slider`
|
|
|
|
|
> compuesto**: existe `resolveSliderDragSound` y arrastrar el scrubber
|
|
|
|
|
> sonorizaría por encima del contenido — verificar con stamp real y, si
|
|
|
|
|
> dispara, la regla es `[data-media-player][data-media='audio'] [data-slider]`.
|
|
|
|
|
> 5. **D-AP.7 se reescribe como *ducking***: con un motor dueño del earcon y del
|
|
|
|
|
> contenido, deja de ser regla de cascada y pasa a capacidad del motor.
|
|
|
|
|
>
|
|
|
|
|
> **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.
|
|
|
|
|
|
|
|
|
|
| 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** | **Regla sema probablemente 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` | La firma de play/pause nunca se aplicaría. **Verificar en F0 con stamp real en navegador**; si se confirma, es fix de una línea (`onPlayButton`). |
|
|
|
|
|
| **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) |
|
|
|
|
|
| **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. 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.
|
|
|
|
|
|
|
|
|
|
**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** | (a) heredar el pack actual · (b) **silenciar el canal sonoro en audio** · (c) pack propio completo | **(b)**. Doctrina 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. Regla nueva en el pack: selector `[data-media-player][data-media='audio']` → `channels: ['haptic']`. Es una regla que **añade carácter sin tocar las primitivas del intent** (regla dura de la cascada). |
|
|
|
|
|
| **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 y waveform 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. |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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
|
|
|
|
|
│ +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` | `sustain` · `end` | — | `provider` | `coincident` | Cierre del proceso de buffering. **Solo si** F0 confirma que `sustain-loading` (`persistence: 'stateBound'`) no se limpia solo; si se limpia, **no se declara** (regla B.5: un cambio no perceptible no es evento). |
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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** | Pista secundaria («buffered»/«secondary range») en `Slider` | `morfo` + `soma` + recipe | Lo consumen soma (valor) y eidos (pintura) → 2-de-3 ✓. 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 | 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) | `sema/components/media-player.ts` | fix de una línea si se confirma | trivial |
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 9. Fases (tras el gate)
|
|
|
|
|
|
|
|
|
|
| Fase | Contenido | Verify |
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
| **F0 — Verificación y firma** | Confirmar H-1 (stamp real en navegador) · confirmar si `sustain-loading` se auto-limpia (decide 7.2) · leer `DefaultControls`, `Slider` provider y la recipe del scrubber ANTES de diseñar (regla `feedback_study_real_recipes_before_building`) · presentar §4 y §6 al usuario y **firmar** | tabla de decisiones firmada en el chat; este plan actualizado |
|
|
|
|
|
| **F1 — Gaps de framework** | G-1 · G-2 · 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` | `validateMorfo` · test del morfo · `morfo:vocabulary` · `translations:check` |
|
|
|
|
|
| **F3 — Soma** | sub-providers de las partes nuevas · MediaSession opt-in (con guarda de singleton) · `formatTime`→G-3 · tests del provider | test del provider verde · `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 + 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.
|
|
|
|
|
- **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 |
|
|
|
|
|
| Sobrecarga de alcance | El v1 es la columna «implementar» de §2 y nada más; todo lo demás lleva disposición escrita |
|