feat(media-player): el player ya le cuenta a la app cuando empieza y cuando acaba

Hasta ahora el componente no exponia NINGUNA api de eventos: cero props `onX` en
la raiz. La app solo podia enterarse de la reproduccion por dos vias malas —
observar mutaciones de los `data-*` del provider, o enchufar un `MediaProvider`
entero por `createProvider`, que es una escotilla pesada para «avisame cuando
acabe».

Y sin embargo el player YA distinguia esos momentos por dentro para sonarlos:
`commit-toggle-play`, `commit-complete`, `commit-fail`. Faltaba sacarlos.

Se abren cuatro, espejo de esos: `onPlay` · `onPause` · `onEnded` · `onError`.

Montan sobre los eventos del ELEMENTO, no sobre los botones, asi que la tecla,
el gesto, las teclas de medios del sistema y un `play()` programatico caen todos
aqui una sola vez, cuando el medio cambio de verdad.

## Dos divergencias deliberadas respecto a la capa perceptual

- El `pause` que acompania al final natural NO se reporta. El elemento lo emite
  justo antes del `ended`, y contar los dos haria que cada pista terminase dos
  veces. Es la misma guarda `!s.ended` que ya usa el commit.
- `onEnded` SI dispara cuando el final viene de un salto al final, aunque
  `commit-complete` se lo salte. Esa exencion existe para que un salto no SUENE
  como una consumacion; una app que encola la siguiente pista necesita el final
  venga de donde venga. Las dos reglas van en el test.

Leidos tarde (`Active`, como `onValueCommit` del Slider) para que cambiar el
handler a mitad de reproduccion se respete.

La demo los pinta en una lectura `lifecycle` al lado de `trace`, a proposito:
`trace` es lo que el player SUENA, `lifecycle` es lo que le CUENTA a la app.

Verificado en Chrome con un <video> real: play → onPlay, pause → onPause,
final natural → onEnded SIN un onPause espurio delante.

Test nacido en rojo (desconectadas las 4 llamadas: 0 invocaciones).
player + waveform 19/19 · check 0 errores propios (78, los mismos de antes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent ee51172d8f
commit 5cbb03904d

@ -19,6 +19,10 @@
ariaLabel = undefined,
metadata = undefined,
createProvider,
onPlay,
onPause,
onEnded,
onError,
children,
child,
...restProps
@ -35,6 +39,10 @@
dir: activeDir(() => dir, soma),
ariaLabel: readableActive(() => ariaLabel),
metadata: readableActive(() => metadata),
onPlay: readableActive(() => onPlay),
onPause: readableActive(() => onPause),
onEnded: readableActive(() => onEnded),
onError: readableActive(() => onError),
// svelte-ignore state_referenced_locally
createProvider
});

@ -604,6 +604,55 @@ describe('MediaPlayerProvider', () => {
cleanup();
dom.dispose();
});
it('reports the playback lifecycle to the app', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const fake = fakeEngine();
const onPlay = vi.fn();
const onPause = vi.fn();
const onEnded = vi.fn();
const onError = vi.fn();
const opts = {
...mediaPlayerOpts(root, () => fake.engine),
onPlay: state<(() => void) | undefined>(onPlay),
onPause: state<(() => void) | undefined>(onPause),
onEnded: state<(() => void) | undefined>(onEnded),
onError: state<(() => void) | undefined>(onError)
};
const { result: provider, cleanup } = withEffectRoot(() => {
const p = MediaPlayerProvider.create(opts);
p.registerMedia(document.createElement('video'));
return p;
});
fake.emit('play', { paused: false });
expect(onPlay).toHaveBeenCalledOnce();
fake.emit('pause', { paused: true });
expect(onPause).toHaveBeenCalledOnce();
// The `pause` the element fires as it reaches the end must NOT be
// reported: the end speaks for itself, and a track that finished would
// otherwise announce it twice.
fake.emit('pause', { paused: true, ended: true });
expect(onPause).toHaveBeenCalledOnce();
fake.emit('ended', { ended: true });
expect(onEnded).toHaveBeenCalledOnce();
// A jump to the end is exempt from the perceptual `commit-complete` (it
// must not SOUND like a completion) but not from the callback: an app
// queueing the next track needs the end whatever caused it.
provider.seek(provider.duration);
fake.emit('ended', { ended: true });
expect(onEnded).toHaveBeenCalledTimes(2);
fake.emit('error', { error: true });
expect(onError).toHaveBeenCalledOnce();
cleanup();
dom.dispose();
});
});
/** Text of a cue fragment, for asserting without leaning on the DOM shape. */

@ -67,6 +67,15 @@ interface MediaPlayerOpts
* touches `navigator.mediaSession` (D-AP2.5).
*/
metadata?: Active<SoundMediaMetadata | undefined>;
/**
* Playback lifecycle for the app — the same moments the perceptual layer
* already fires on, handed out as callbacks. Read late (`Active`) so a
* consumer swapping the handler mid-playback is honoured.
*/
onPlay?: Active<(() => void) | undefined>;
onPause?: Active<(() => void) | undefined>;
onEnded?: Active<(() => void) | undefined>;
onError?: Active<(() => void) | undefined>;
}
/**
@ -322,7 +331,14 @@ export class MediaPlayerProvider {
const skipped = this.skipToEnd;
this.skipToEnd = false;
if (!skipped) void this.runtime.trigger('commit-complete');
} else if (event === 'error') void this.runtime.trigger('commit-fail');
// The callback does NOT take the `skipped` exemption: that gate exists so
// a jump does not SOUND like a completion, but an app queueing the next
// track needs the end either way.
this.opts.onEnded?.current?.();
} else if (event === 'error') {
void this.runtime.trigger('commit-fail');
this.opts.onError?.current?.();
}
// Buffering opens the sustain (H-9, wired as signed in D-AP2.10): the
// morfo binds it `stateBound` to `data-buffering`, which clears on
// canplay — the process closes with the state, no explicit end event.
@ -340,6 +356,10 @@ export class MediaPlayerProvider {
// completion, whatever a previous seek did.
if (event === 'play') this.skipToEnd = false;
void this.runtime.trigger('commit-toggle-play');
// Same branch, same guard: the `pause` that rides the natural end is
// already excluded above, so a track never reports both.
if (event === 'play') this.opts.onPlay?.current?.();
else this.opts.onPause?.current?.();
}
// The muted commit needs the transition — `volumechange` also fires for
// plain volume moves, whose perceptual signal is the composed Slider's.

@ -48,6 +48,31 @@ export type MediaPlayerProps = WithChild<{
* `uix.sound.media(el)`, element-less embeds simply have no citizenship.
*/
createProvider?: (el: HTMLMediaElement) => MediaProvider;
/**
* Playback lifecycle, for the app. These mirror the moments the player
* already distinguishes internally to sound them (`commit-toggle-play`,
* `commit-complete`, `commit-fail`) — until now the only way to observe
* them from outside was to plug a whole {@link MediaProvider}.
*
* They ride the ELEMENT's events, not the buttons: a hotkey, a tap, the OS
* media keys and a programmatic `play()` all land here once, when the medium
* actually changed.
*/
onPlay?: () => void;
/**
* Not fired for the `pause` that accompanies the natural end — that end
* speaks through {@link onEnded}, and reporting both would make every track
* finish twice.
*/
onPause?: () => void;
/**
* Fired on EVERY `ended`, including one produced by scrubbing to the end.
* The perceptual `commit-complete` suppresses that case (a jump is not a
* completion, so it must not sound like one) — but an app queueing the next
* track needs to know the medium is over whatever caused it.
*/
onEnded?: () => void;
onError?: () => void;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -124,6 +124,16 @@
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
// ── App-facing lifecycle callbacks ──────────────────────────────────────
let lifecycle = $state<string[]>([]);
const logLife = (name: string) => (lifecycle = [name, ...lifecycle].slice(0, 3));
const lifecycleProps = {
onPlay: () => logLife('onPlay'),
onPause: () => logLife('onPause'),
onEnded: () => logLife('onEnded'),
onError: () => logLife('onError')
};
// ── Live trace (data-event) ─────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
@ -223,6 +233,7 @@
{artist}
artworkSrc={SAMPLES.sintel.poster}
ariaLabel={ariaLabel || undefined}
{...lifecycleProps}
/>
{:else}
<MediaPlayer
@ -232,6 +243,7 @@
{size}
metadata={videoMetadata}
ariaLabel={ariaLabel || undefined}
{...lifecycleProps}
>
<MediaPlayer.Media {src} preload="metadata">
{#if captionsTrack}
@ -268,6 +280,19 @@
{/key}
</div>
</div>
<!-- The APP-facing callbacks, next to the semantic trace on purpose: the
two answer different questions. `trace` is what the player SOUNDS;
`lifecycle` is what it TELLS the app. -->
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>lifecycle</span>
{#if lifecycle.length === 0}
<span>onPlay · onPause · onEnded · onError land here</span>
{:else}
{#each lifecycle as entry}
<span><span data-uix-stage-trace-event>{entry}</span></span>
{/each}
{/if}
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.entries.length === 0}

Loading…
Cancel
Save

Powered by TurnKey Linux.