audit(uix): characterize AudioPlayer without changing playback

astra
dev 2 weeks ago
parent efe6ae38ce
commit efa1cbe891

@ -1089,6 +1089,32 @@ siempre presentes, sin cambiar el contrato opcional del provider.
No se acredita lector de pantalla,
touch, otros motores, decode ni transporte/reproducción de audio.
### Skin de audio: AudioPlayer (2026-09-27)
La [ficha de AudioPlayer](./audit-uix-2026-09-19/audio-player.md) contrasta el
skin, las capas actuales de MediaPlayer, transporte Sound, recetas y decisiones
firmadas. Las referencias primarias son Vidstack, Media Chrome, Video.js y Plyr;
APG Slider y Media Seek se aplican a los controles compuestos. AudioPlayer ya
reutiliza el motor; no se fuerza otro helper para su derivación corta de metadata.
**Cero líneas de producción reducidas**, sin modificar CSS ni comportamiento.
Cuatro canarios con servicios reales y WAV PCM local silencioso cubren carga,
play/pause/mute, callbacks, metadata derivada y explícita, teclado, volumen,
reinicio, velocidad, variantes, tallas, peaks y dirección explícita. Pasan
22/22 junto al provider existente; la baseline separada pasa 39/39 en tres
archivos. Se inspeccionan cinco capturas completas a escala nativa, ampliando
el viewport de la prueba para el escenario ancho. Una expectativa inicial
de data-size y el recorte del iframe se corrigen en el fixture, no en producción.
Las sondas reproducen Q86 (tres thumbs sin nombre accesible) y Q87 (sin
texto temporal con y sin peaks). Se repiten con 22 canarios verdes, se retiran
y se conservan sus artefactos. Quedan para después de la auditoría. El inventario
alcanza **70 fichas**, con fase 6 abierta; MediaPlayer requiere su propio lote.
El gate de cierre pasa 509/509 archivos y 5607/5607 tests, src/scripts sin
errores, 896 docs sin errores ni warnings y apps/base con check/build y smoke
27/27 sin errores de página/consola/CSP. No se acredita sonido físico, tecnología de
asistencia, teclas de medios del sistema, pantalla de bloqueo, PiP ni vídeo.
### Revisión de Chronos (2026-09-22)
El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede

@ -0,0 +1,110 @@
---
title: UIX — auditoría de AudioPlayer
type: notes
audience: human + agent
authority: process — caracterización del skin existente, sin cambios de comportamiento
status: reviewed
---
# AudioPlayer
Fecha: 2026-09-27. Alcance autorizado: duplicación, refactorización neutral y
registro de defectos; web queda fuera. Se leen los cuatro documentos de
pre-flight, los cinco archivos del skin, el contrato Eidos, el plan v2 firmado,
la ficha histórica de MediaPlayer y las decisiones de su hilo de sonido.
También se leen las capas y wrappers actuales de MediaPlayer, su puerto,
el transporte nativo del servicio Sound y las traducciones.
## Baseline y referencias
No hay componente Air media-player/audio-player en morfo-runtime. La ficha de
julio describe un contrato anterior; el Morfo vigente tiene 25 partes y once
eventos, expresión pack. AudioPlayer no crea otro motor: compone MediaPlayer
y AudioLayout sobre ese mismo contrato. La decisión sigue vigente.
| Referencia primaria | Superficie relevante | Decisión de esta auditoría |
| ------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [Vidstack](https://vidstack.io/docs/player/) | Proveedores, controles compuestos, audio y video, accesibilidad y layouts. | Conservar puerto y composición existentes; no importar otro motor. |
| [Media Chrome](https://github.com/muxinc/media-chrome) | Controles separados sobre audio/video nativos. | Conservar controles sobre estado del medio; no duplicar el transporte. |
| [Video.js](https://videojs.org/) | Separación de UI y medio mediante contratos públicos. | Comparar responsabilidades; no ampliar los adaptadores ni calidad. |
| [Plyr](https://github.com/sampotts/plyr) | Audio, transporte, volumen, velocidad y controles configurables. | Caracterizar los controles disponibles sin incorporar funciones ajenas. |
Consulta de fuentes primarias realizada el 2026-09-27. La URL documental
antigua de Media Chrome no responde al lector; se usa su README oficial.
Video.js redirige su dominio anterior; su sitio ya presenta v10 RC y Plyr
anuncia la convergencia futura. La comparación es de superficie, no una
recomendación de migración. El [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/)
se aplica a los controles embebidos, no a un supuesto patrón APG de player.
## Arquitectura y decisiones preservadas
AudioPlayer es una raíz Eidos competidora. Sus cuatro variantes card/row/bar/inline
y sus tallas sm/md/lg/xl son visuales; el estado y los eventos siguen en
MediaPlayer/Soma y el transporte en Sound. La identidad visible deriva metadata
salvo override explícito. Ese dato se captura al registrar el medio, según el
contrato vigente. Una prop undefined con identidad visible no desactiva esa
derivación; no se inventa una nueva política durante la auditoría.
Sin peaks queda el raíl; con peaks TimeSlider compone Waveform. No hay onda
genérica ni descarga/decodificación de picos desde el skin. El buffered sigue
siendo SecondaryRange del Slider. RateFloat usa los presets del provider.
El hilo histórico documenta aprobación de las cuatro variantes y el suelo del
scrubber de bar. Su ancho mínimo de diseño y el mute estrujado por debajo del
umbral tienen disposición previa. Se conserva ese diseño; una futura container
query y orden de descarte son trabajo funcional separado.
## Duplicación y candidatos
El skin tiene un único punto de composición y no replica el motor. Extraer su
derivación corta de metadata a otro helper no acredita reducción ni un segundo
consumidor real. Las conexiones repetidas de MediaPlayer y sus partes se
evaluarán en su propio lote, manteniendo createProvider capturado y separado
de la bolsa reactiva. La base común de partes pasivas ya existe.
## Caracterización y hallazgos
La línea base existente pasa **39/39 tests en tres archivos**: provider de
MediaPlayer, Morfo y transporte del servicio Sound. component:audit de
MediaPlayer devuelve PASS sin errores. Esto no certifica todo AudioPlayer.
Se incorporan cuatro canarios con ActiveUix/Soma/ActiveEidos reales y un WAV
PCM local de veinte segundos, con muestras silenciosas. Preparan carga,
play/pause/mute, callbacks, metadata, cuatro variantes, tallas, dirección,
peaks, teclado/volumen sin fuga de flechas, reinicio, velocidad y override
de identidad. La primera ejecución pasa 21/22 y falla por esperar data-size
en el contenedor de TimeSlider: Soma lo reenvía al Slider interior. Se corrige
únicamente esa expectativa; la repetición pasa **22/22 en dos archivos**.
Las capturas iniciales recortan el escenario ancho por el iframe de 414px;
se amplía explícitamente el viewport de la prueba a 1100×1000 y se repiten:
**4/4 pasan**. Se inspeccionan las cinco capturas a escala nativa, completas:
card/row/bar/inline y row RTL con Waveform. Los botones adicionales de reinicio
y salto son children del escenario. No se confunde esa composición con la
salida mínima del skin.
Tres sondas de nombre accesible confirman Q86: Tiempo, Volumen y Tiempo con
Waveform tienen etiqueta en el root Slider, pero sus thumbs reciben nombre
vacío. Q87 registra aria-valuetext ausente en el control de tiempo después
de seek nativo a 10 segundos sobre duración 20. El dominio numérico sigue
correcto. Se contrasta el [ejemplo APG Media Seek](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/).
La primera sonda de texto recibe null y toMatch rechaza ese tipo; se precisa
la expectativa de presencia y se añade la rama con peaks. La repetición pasa
**22/22 canarios en dos archivos** y fallan las cinco sondas en otro archivo
con los tres nombres vacíos y los dos atributos null. Se retiran las sondas
y se preservan fuentes, logs y capturas en tmp. Q86/Q87 quedan en el registro
para después de la auditoría, sin reparación funcional.
La colisión potencial del temporizador idle requiere su propia reproducción
con varios MediaPlayers. No se da por confirmada ni se cierra ese lote por
haber probado este skin.
No se modifica código ejecutable de producción: **0 líneas reducidas**.
Se mantienen cuatro canarios y se precisa el README del skin. El gate de
cierre termina verde: **509/509 archivos y 5607/5607 tests**, tipos sin errores
en src/scripts, 896 docs sin errores ni warnings, arquitectura sin errores y
apps/base con check/build y smoke 27/27, sin errores de página/consola/CSP.
La revisión final retira una sección documental duplicada durante la edición;
se repiten formato y guards de documentación, sin cambios ejecutables.
No se acredita sonido físico, teclas del sistema, pantalla de bloqueo,
lector de pantalla, touch, otros motores, PiP ni reproducción de vídeo.

@ -167,6 +167,13 @@ Lote posterior de Waveform:
| ------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nombre del scrubber | Q85 | Hacer llegar el nombre propio o localizado al thumb del Slider compuesto; cubrir teclado y valuetext sin añadir ARIA al div genérico de Waveform. |
Lotes posteriores de los controles MediaPlayer usados por AudioPlayer:
| Lote posterior | ID | Condición y prueba de cierre |
| --------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nombres de tiempo y volumen | Q86 | Nombrar los thumbs de TimeSlider con y sin peaks y de VolumeSlider; cubrir traducciones, teclado y composición independiente del skin. |
| Texto accesible del tiempo | Q87 | Adjudicar y conectar el formato temporal en ambos scrubbers, conservar dominio numérico y probar cambios de tiempo/duración e idiomas con tecnología de asistencia. |
Al aparecer otro defecto durante la auditoría se añade aquí y en la tabla de
evidencia de abajo con un ID existente del ledger o un `Q*` nuevo, reproducción
o límite de la evidencia, consumidor afectado y prueba de cierre. Al terminar
@ -261,6 +268,37 @@ Ninguno entra en la cola de correcciones confirmadas por inferencia.
| Q73 | Cropper puntero | Down ID1 en Selection y move ID2 de 32px desplazan crop.x de 0.25 a 0.35; la sesión no conserva pointerId. | Chromium montado, eventos sintéticos y captura mock; sonda retirada | Cubrir selección, handle y pan; reproducir multi-touch nativo. |
| Q74 | Cropper metadatos | apg afirma keyboard nudge desde C5, pero Selection/Handle no tienen handler de teclado y README lo difiere. El campo de metadatos se conserva. | Discrepancia confirmada por lectura; efecto en consumidores de metadatos sin reproducir | Adjudicar y alinear la justificación; no implementar teclado por inferencia. |
### AudioPlayer y controles MediaPlayer: Q86–Q87
Fecha: 2026-09-27. Un consumidor Eidos monta ActiveUix, Soma, ActiveEidos y
Sound reales, con un WAV PCM local de veinte segundos y muestras silenciosas.
Se prueban los controles compartidos que AudioLayout monta; la causa está en
las composiciones Soma de MediaPlayer, no en un motor nuevo de AudioPlayer.
**Q86 — nombres de sliders**: los roots Slider tienen aria-label Buscar o
Volumen, pero sus thumbs con role slider reciben nombre accesible vacío. Tres
sondas con matcher de nombre fallan: tiempo sin peaks, volumen y tiempo con
Waveform. Es una ocurrencia distinta de la pauta reproducida en Q70/Q77/Q85;
no se atribuye por inferencia otro defecto al motor Slider. El cierre contrasta
[APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/).
**Q87 — texto temporal**: tras la tecla nativa 5, el audio queda en 10 segundos,
el thumb publica aria-valuenow=10 y aria-valuemax=20, pero no tiene
aria-valuetext. La ausencia se contrasta con el ejemplo primario
[APG Media Seek](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/),
que explica el formato temporal de segundos como ayuda a la comprensión.
Es un gap de accesibilidad del consumidor, no un fallo de seek. No se impone
por inferencia una frase española exacta ni se afirma qué anuncia un lector.
En la primera ejecución pasan los 22 canarios de otros dos archivos y fallan
las cuatro sondas. La sonda de texto encuentra null y el matcher toMatch
rechaza su tipo; se precisa primero la expectativa de presencia y se amplía
la prueba a las dos ramas, sin editar producción. La repetición pasa los mismos
22 canarios y fallan las cinco sondas: tres nombres vacíos y dos atributos
temporales null. Se retiran las sondas y se conservan sus fuentes, logs y
capturas en tmp. No se acredita tecnología de asistencia ni touch con estas sondas.
Q86 y Q87 permanecen abiertos para después de la auditoría.
### Waveform: Q85
Fecha: 2026-09-27. **Q85 — Waveform ARIA**: el thumb con role slider recibe

@ -54,3 +54,32 @@ puede escribir, y el guard R-5.4 comprueba que ninguna miente.
- `live-font-weight` — the LIVE badge only exists while the provider reflects a live stream (display:none otherwise) and the demo plays a file; mounted with [data-live] 2026-08-23 -> reaches (600 -> 333)
- `live-letter-spacing` — same badge; mounted -> reaches (1.28px -> 7px)
## Auditoría del skin (2026-09-27)
La [ficha de auditoría](../../../../../docs/process/audit-uix-2026-09-19/audio-player.md)
contrasta las referencias de reproductores y las decisiones firmadas, y conserva
la composición sobre MediaPlayer. No se añade otro motor ni se cambia la receta.
No se extrae la derivación corta de metadata: no hay un segundo consumidor que
justifique otro helper. Reducción de código ejecutable en este skin: **0 líneas**.
Cuatro canarios mantenidos montan ActiveUix/Soma/ActiveEidos y el transporte Sound
reales con un WAV PCM local silencioso. Cubren carga, reproducción/pausa/mute,
callbacks, metadata derivada y explícita, teclado, volumen, reinicio, presets de
velocidad, las cuatro variantes, tallas y dirección explícita. Con `peaks`,
TimeSlider compone Waveform; sin ellos conserva el Slider de raíl. Cambiar variante
mantiene el mismo elemento audio. Las capturas se inspeccionan a escala nativa
con viewport de prueba ancho, incluidos controles adicionales por `children`.
La metadata se proyecta al registrar el medio en Sound. Actualizar sólo el título
visible no implica actualizar una metadata explícita ya registrada. No se amplía
esa política en la auditoría. Las decisiones previas sobre el ancho de bar y su
descarte responsive permanecen vigentes.
Las sondas de los controles compartidos registran Q86 (nombre vacío de los thumbs)
y Q87 (falta de texto temporal en el scrubber) para después de la auditoría.
El estado y la prueba de cierre viven en el
[registro](../../../../../docs/process/audit-uix-2026-09-19/findings-register.md).
El gate o el resultado de los canarios no dan estos defectos por reparados.
No se acredita lector de pantalla, touch asistido, sonido físico, teclas del
sistema, pantalla de bloqueo, PiP ni reproducción de vídeo con este fixture.

@ -0,0 +1,144 @@
import { beforeAll, expect, it, vi } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { renderAudioPlayer } from './test/audio-player-audit-view';
beforeAll(async () => {
await page.viewport(1100, 1000);
await userEvent.keyboard('{Shift}');
}, 60_000);
it('reflects native play, pause, mute and source metadata through the real sound service', async () => {
const view = renderAudioPlayer();
try {
await view.ready();
expect(view.root.getAttribute('role')).toBe('region');
await expect.element(view.root).toHaveAccessibleName('Reproductor de prueba');
expect(view.root.getAttribute('data-media')).toBe('audio');
expect(view.audio.preload).toBe('metadata');
expect(view.audio.controls).toBe(false);
expect(view.part('time').textContent?.trim()).toBe('0:00');
await expect.element(view.part('play-button')).toHaveAccessibleName('Reproducir');
let trusted = false;
view.part('play-button').addEventListener('click', (event) => (trusted = event.isTrusted));
await userEvent.click(view.part('play-button'));
await vi.waitFor(() => expect(view.audio.paused).toBe(false));
await expect.element(view.part('play-button')).toHaveAccessibleName('Pausar');
expect(trusted).toBe(true);
expect(view.root.hasAttribute('data-paused')).toBe(false);
expect(view.onPlay).toHaveBeenCalledOnce();
expect(navigator.mediaSession.metadata?.title).toBe('Pista de prueba');
expect(navigator.mediaSession.metadata?.artist).toBe('Autor de prueba');
await userEvent.click(view.part('mute-button'));
await vi.waitFor(() => expect(view.audio.muted).toBe(true));
expect(view.part('mute-button').getAttribute('aria-pressed')).toBe('true');
await expect.element(view.part('mute-button')).toHaveAccessibleName('Activar sonido');
await userEvent.click(view.part('play-button'));
await vi.waitFor(() => expect(view.audio.paused).toBe(true));
expect(view.onPause).toHaveBeenCalledOnce();
expect(view.onEnded).not.toHaveBeenCalled();
await userEvent.click(view.button('Toggle loop'));
expect(view.audio.loop).toBe(true);
} finally {
await view.dispose();
}
}, 30_000);
it('keeps layout variants and control sizes on one media element', async () => {
const view = renderAudioPlayer();
try {
await view.ready();
const audio = view.audio;
expect(window.innerWidth).toBe(1100);
for (const variant of ['card', 'row', 'bar', 'inline']) {
await userEvent.click(view.button(`Variant ${variant}`));
expect(view.root.getAttribute('data-variant')).toBe(variant);
expect(view.root.querySelector('audio')).toBe(audio);
expect(getComputedStyle(view.part('identity')).display === 'none').toBe(variant === 'inline');
expect(getComputedStyle(view.part('mute-button')).display === 'none').toBe(
variant === 'inline'
);
expect(getComputedStyle(view.part('artist')).display === 'none').toBe(variant === 'bar');
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
element: view.root,
path: `../../../../../tmp/audio-player-${variant}.png`
});
}
await userEvent.click(view.button('Variant row'));
for (const size of ['sm', 'md', 'lg', 'xl']) {
await userEvent.click(view.button(`Size ${size}`));
expect(view.root.getAttribute('data-size')).toBe(size);
expect(view.part('play-button').getAttribute('data-size')).toBe(size);
expect(
view.part('time-slider').querySelector('[data-slider]')?.getAttribute('data-size')
).toBe(size);
}
await userEvent.click(view.button('Toggle peaks'));
expect(view.part('time-slider').querySelector('[data-waveform]')).not.toBeNull();
await userEvent.click(view.button('Toggle direction'));
expect(view.root.getAttribute('dir')).toBe('rtl');
expect(view.root.getAttribute('data-dir')).toBe('rtl');
expect(getComputedStyle(view.part('play-button').querySelector('svg')!).scale).toBe('-1 1');
expect(view.root.querySelector('[data-slider]')?.getAttribute('dir')).toBe('rtl');
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
element: view.root,
path: '../../../../../tmp/audio-player-row-rtl-waveform.png'
});
expect(view.onPlay).not.toHaveBeenCalled();
expect(view.onPause).not.toHaveBeenCalled();
} finally {
await view.dispose();
}
}, 30_000);
it('seeks and changes volume through native keyboard without leaking slider arrows', async () => {
const view = renderAudioPlayer();
try {
await view.ready();
view.root.focus();
await userEvent.keyboard('{ArrowRight}');
await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(3));
await userEvent.keyboard('{ArrowLeft}');
await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(0));
await userEvent.keyboard('5');
await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(10));
const volume = view.part('volume-slider').querySelector<HTMLElement>('[data-slider-thumb]')!;
volume.focus();
await userEvent.keyboard('{Home}');
await vi.waitFor(() => expect(view.audio.volume).toBe(0));
expect(view.audio.currentTime).toBeCloseTo(10);
await userEvent.keyboard('{ArrowRight}');
await vi.waitFor(() => expect(view.audio.volume).toBeCloseTo(0.01));
expect(view.audio.currentTime).toBeCloseTo(10);
await userEvent.click(view.part('restart-button'));
await vi.waitFor(() => expect(view.audio.currentTime).toBeCloseTo(0));
expect((view.part('skip-button') as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(view.part('rate-button'));
const slower = page.getByRole('menuitemradio', { name: '0.5×', exact: true });
await slower.click();
await vi.waitFor(() => expect(view.audio.playbackRate).toBe(0.5));
expect(view.part('rate-button').getAttribute('data-rate')).toBe('0.5');
expect(view.part('rate-button').textContent?.trim()).toBe('0.5×');
} finally {
await view.dispose();
}
}, 30_000);
it('preserves explicitly supplied session metadata instead of deriving the visible identity', async () => {
const view = renderAudioPlayer({ title: 'Identidad explícita', artist: 'Autor explícito' });
try {
await view.ready();
await userEvent.click(view.part('play-button'));
await vi.waitFor(() =>
expect(navigator.mediaSession.metadata?.title).toBe('Identidad explícita')
);
expect(navigator.mediaSession.metadata?.artist).toBe('Autor explícito');
await userEvent.click(view.part('play-button'));
await userEvent.click(view.button('Change identity'));
expect(view.part('title').textContent?.trim()).toBe('Nueva pista');
expect(navigator.mediaSession.metadata?.title).toBe('Identidad explícita');
} finally {
await view.dispose();
}
}, 30_000);

@ -0,0 +1,80 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { setActiveUix, type ActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import { ActiveEidos } from '$uix/eidos';
import AudioPlayer from '../index';
import { MediaPlayer } from '../../media-player';
import type { AudioPlayerVariant } from '../types';
import type { MediaPlayerSize } from '../../media-player/types';
import type { SoundMediaMetadata } from '$sound';
let {
uix,
src,
artworkSrc,
metadata,
onPlay,
onPause,
onEnded
}: {
uix: ActiveUix;
src: string;
artworkSrc: string;
metadata?: SoundMediaMetadata;
onPlay: () => void;
onPause: () => void;
onEnded: () => void;
} = $props();
// svelte-ignore state_referenced_locally
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: false });
onDestroy(() => eidos.dispose());
let variant = $state<AudioPlayerVariant>('card');
let size = $state<MediaPlayerSize>('md');
let dir = $state<'ltr' | 'rtl'>('ltr');
let peaks = $state<number[] | undefined>(undefined);
let loop = $state(false);
let title = $state('Pista de prueba');
</script>
<section style="width: 1000px; padding: 16px; background: white; color: black;">
<h1>AudioPlayer audit</h1>
<AudioPlayer
{src}
{artworkSrc}
artworkAlt="Carátula de prueba"
{title}
artist="Autor de prueba"
{metadata}
{variant}
{size}
{dir}
{peaks}
{loop}
seekStep={3}
ariaLabel="Reproductor de prueba"
{onPlay}
{onPause}
{onEnded}
>
<MediaPlayer.RestartButton />
<MediaPlayer.SkipButton direction="previous" />
<MediaPlayer.SkipButton direction="next" />
</AudioPlayer>
<div>
{#each ['card', 'row', 'bar', 'inline'] as nextVariant}
<button onclick={() => (variant = nextVariant as AudioPlayerVariant)}>
Variant {nextVariant}
</button>
{/each}
{#each ['sm', 'md', 'lg', 'xl'] as nextSize}
<button onclick={() => (size = nextSize as MediaPlayerSize)}>Size {nextSize}</button>
{/each}
<button onclick={() => (dir = dir === 'ltr' ? 'rtl' : 'ltr')}>Toggle direction</button>
<button onclick={() => (peaks = peaks ? undefined : [0, 1, 0.5, 0.25])}>Toggle peaks</button>
<button onclick={() => (loop = !loop)}>Toggle loop</button>
<button onclick={() => (title = 'Nueva pista')}>Change identity</button>
</div>
</section>

@ -0,0 +1,95 @@
import { flushSync, mount, unmount } from 'svelte';
import { vi } from 'vitest';
import { createActiveUix } from '$active-uix';
import type { SoundMediaMetadata } from '$sound';
import '../../../index.css';
import AudioPlayerAuditHarness from './audio-player-audit-harness.svelte';
// A native PCM WAV with silent samples: media loading/playback is real,
// while the fixture emits no audible content and needs no network server.
function silentWav(seconds: number): Blob {
const rate = 8000;
const bytes = seconds * rate * 2;
const buffer = new ArrayBuffer(44 + bytes);
const data = new DataView(buffer);
const text = (offset: number, value: string) => {
for (let i = 0; i < value.length; i++) data.setUint8(offset + i, value.charCodeAt(i));
};
text(0, 'RIFF');
data.setUint32(4, 36 + bytes, true);
text(8, 'WAVE');
text(12, 'fmt ');
data.setUint32(16, 16, true);
data.setUint16(20, 1, true);
data.setUint16(22, 1, true);
data.setUint32(24, rate, true);
data.setUint32(28, rate * 2, true);
data.setUint16(32, 2, true);
data.setUint16(34, 16, true);
text(36, 'data');
data.setUint32(40, bytes, true);
return new Blob([buffer], { type: 'audio/wav' });
}
export function renderAudioPlayer(metadata?: SoundMediaMetadata) {
const src = URL.createObjectURL(silentWav(20));
const artworkSrc =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#126878"/><circle cx="100" cy="100" r="60" fill="#e6af36"/></svg>'
);
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'es' },
prefs: { storage: false },
projectPrefs: false
});
const target = document.createElement('div');
document.body.appendChild(target);
const onPlay = vi.fn();
const onPause = vi.fn();
const onEnded = vi.fn();
const component = mount(AudioPlayerAuditHarness, {
target,
props: { uix, src, artworkSrc, metadata, onPlay, onPause, onEnded }
});
flushSync();
const root = target.querySelector<HTMLElement>('[data-media-player]')!;
const audio = root.querySelector<HTMLAudioElement>('audio')!;
return {
target,
root,
audio,
uix,
artworkSrc,
onPlay,
onPause,
onEnded,
part(name: string) {
return root.querySelector<HTMLElement>(`[data-media-player-${name}]`)!;
},
button(text: string) {
return Array.from(target.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text
)!;
},
async ready() {
await vi.waitFor(
() => {
expectLoaded(audio);
},
{ timeout: 5000 }
);
},
async dispose() {
audio.pause();
await unmount(component);
uix.dispose();
URL.revokeObjectURL(src);
target.remove();
}
};
}
function expectLoaded(audio: HTMLAudioElement) {
if (audio.readyState < 3 || audio.duration !== 20) throw new Error('Native audio not ready');
}
Loading…
Cancel
Save

Powered by TurnKey Linux.