feat(media-player): con dos idiomas de subtitulos no habia forma de llegar al segundo

`toggleCaptions()` solo alcanza la PRIMERA pista, y a proposito: es un toggle, y
las demas se quedan en `disabled` porque poner una en `hidden` OBLIGA al
navegador a descargar su VTT — encenderlas todas bajaria todos los ficheros de
golpe. La consecuencia es que un video con dos idiomas no tenia ninguna forma de
llegar al segundo.

`<MediaPlayer.CaptionFloat>` es la lista: `Off` + una entrada por pista, mismo
patron que el `RateFloat` de hoy (DropdownMenu + RadioGroup, con el
`CaptionButton` real de disparador y su toggle anulado con `preventDefault()` en
la costura). OPT-IN: el boton solo sigue siendo el control correcto para una
fuente de una sola pista.

Por debajo, dos anadidos a soma y ninguno al puerto:

- `selectCaptionTrack(track | null)` — pone UNA en `hidden` y el resto en
  `disabled`, por la misma razon de descarga.
- `captionTrackList` + `activeCaptionTrack` — espejos reactivos. El
  `TextTrackList` vivo es una coleccion del DOM y NO notifica, asi que un chrome
  que LISTA las pistas no se enteraba de una que llegase tarde. Los refresca
  `syncCaptions`, que ya corria en addtrack / removetrack / change.

Cambiar de idioma NO dispara `commit-toggle-captions`: solo lo hace una
transicion real de encendido/apagado. Pasar de ingles a espanol con los
subtitulos ya puestos no es un toggle — la misma distincion que el commit de
mute hace con `s.muted !== wasMuted`.

De paso, tres claves de texto que se me quedaron ayer solo en el LANGS del
provider y no en el `texts` del morfo (restart, skip-previous, skip-next), mas
`captions-none` para la entrada «Off». El `texts` del morfo ES la superficie i18n
que pinta la demo, asi que faltar ahi es faltar de verdad.

Verificado en Chrome con dos pistas VTT reales: abrir no toca los modos · exacto
UNA pista en `hidden` y el resto en `disabled` · las cues PINTAN el idioma
elegido (English «Inline captions demo track», Espanol «Pista de subtitulos de
prueba») y Off deja la caja vacia, no congelada · panel 94px.

Test nacido en rojo. 17/17 player · morfo:check media-player PASS · eidos-lint
invalid 0 · class-hooks 0 · 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 c73669cde7
commit 21d817c1f6

@ -101,22 +101,23 @@ El modo audio dejó de ser «vídeo colapsado» (las 3 reglas CSS de v1):
## Gaps
| Gap | Disposición | Detalle |
| ---------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Captions (botón + toggle) | **implementar → hecho** | `CaptionButton` togglea el primer text-track a `hidden` y emite `data-captions`; disabled cuando no hay pista. |
| Picture-in-picture | **implementar → hecho** | `PipButton` → `requestPictureInPicture()` / `exitPictureInPicture()`, reflejo por `enter/leavepictureinpicture`, `data-pip`. |
| Auto-hide por inactividad | **implementar → hecho** | `uix.timers` togglea `data-controls`; se revela con actividad de puntero/teclado y se mantiene en pausa/buffering. |
| Tap-to-play (gesture) | **implementar → hecho** | Click en la superficie de vídeo → `togglePlay` (convención YouTube). |
| Title overlay | **implementar → hecho** | Part `Title`; texto + fade con el scrim (`data-controls`). |
| Live | **implementar → hecho** | `data-live` desde `el.duration === Infinity` (puerto `MediaProvider`). |
| Velocidad como lista (`RateFloat`) | **implementar → hecho** | Compone `DropdownMenu` + `RadioGroup` en eidos, con el `RateButton` de disparador. Cierra el defecto de que desde `2×` no se podía bajar (ver §Velocidad flotante). |
| Settings (quality / track) | **diferir** | Lo que queda de la parte `SettingsButton` una vez la velocidad tiene su propia lista: calidad y pista. Sigue sin implementar. |
| Transporte de lista (`RestartButton` · `SkipButton`) | **implementar → hecho** | Volver al inicio + anterior/siguiente. OPT-IN: `AudioLayout` no los compone. Sin eventos sema nuevos, como el `SeekButton` (ver §Transporte). |
| API de eventos para la app | **implementar → hecho** | `onPlay` · `onPause` · `onEnded` · `onError` (+ `onPrevious` / `onNext`). Antes la única vía era enchufar un `MediaProvider` entero. |
| Doble-tap-seek | **diferir** | Necesita debounce click/dblclick sobre el media; el tap-to-play cubre el gesto principal. |
| Thumbnail preview en el scrubber | **diferir** | **Gap del Slider**: no hay pista de preview por hover; requiere extender Slider o una capa eidos-only. |
| Captions surface custom (`Captions` part) | **implementar → hecho** | El player PINTA las cues (ver §Captions abajo). El render nativo se retiró: caía en la franja de la barra de controles y llegaba ilegible. |
| `renderProps` en los botones/partes | **diferir** | Los providers emiten attrs directamente en `props` (pragmático, consistente con el resto del componente); migrar a `renderProps` + `propRef` en el barrido de remediación global. |
| Gap | Disposición | Detalle |
| ---------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Captions (botón + toggle) | **implementar → hecho** | `CaptionButton` togglea el primer text-track a `hidden` y emite `data-captions`; disabled cuando no hay pista. |
| Picture-in-picture | **implementar → hecho** | `PipButton` → `requestPictureInPicture()` / `exitPictureInPicture()`, reflejo por `enter/leavepictureinpicture`, `data-pip`. |
| Auto-hide por inactividad | **implementar → hecho** | `uix.timers` togglea `data-controls`; se revela con actividad de puntero/teclado y se mantiene en pausa/buffering. |
| Tap-to-play (gesture) | **implementar → hecho** | Click en la superficie de vídeo → `togglePlay` (convención YouTube). |
| Title overlay | **implementar → hecho** | Part `Title`; texto + fade con el scrim (`data-controls`). |
| Live | **implementar → hecho** | `data-live` desde `el.duration === Infinity` (puerto `MediaProvider`). |
| Velocidad como lista (`RateFloat`) | **implementar → hecho** | Compone `DropdownMenu` + `RadioGroup` en eidos, con el `RateButton` de disparador. Cierra el defecto de que desde `2×` no se podía bajar (ver §Velocidad flotante). |
| Pista de subtítulos (`CaptionFloat`) | **implementar → hecho** | `Off` + una entrada por pista. El `CaptionButton` es un toggle y sólo alcanza la PRIMERA; con dos idiomas no había forma de llegar a la segunda (ver §Pista de subtítulos). |
| Settings (quality) | **diferir — especificado** | La velocidad tiene lista propia y la pista también, así que al engranaje sólo le queda la calidad… **que no existe en el puerto** (0 campos en `MediaSnapshot`). Contrato en [`design-media-quality.md`](../../../../docs/decisions/design-media-quality.md). |
| Transporte de lista (`RestartButton` · `SkipButton`) | **implementar → hecho** | Volver al inicio + anterior/siguiente. OPT-IN: `AudioLayout` no los compone. Sin eventos sema nuevos, como el `SeekButton` (ver §Transporte). |
| API de eventos para la app | **implementar → hecho** | `onPlay` · `onPause` · `onEnded` · `onError` (+ `onPrevious` / `onNext`). Antes la única vía era enchufar un `MediaProvider` entero. |
| Doble-tap-seek | **diferir** | Necesita debounce click/dblclick sobre el media; el tap-to-play cubre el gesto principal. |
| Thumbnail preview en el scrubber | **diferir** | **Gap del Slider**: no hay pista de preview por hover; requiere extender Slider o una capa eidos-only. |
| Captions surface custom (`Captions` part) | **implementar → hecho** | El player PINTA las cues (ver §Captions abajo). El render nativo se retiró: caía en la franja de la barra de controles y llegaba ilegible. |
| `renderProps` en los botones/partes | **diferir** | Los providers emiten attrs directamente en `props` (pragmático, consistente con el resto del componente); migrar a `renderProps` + `propRef` en el barrido de remediación global. |
## Captions — el player pinta las cues (2026-08-04)
@ -216,6 +217,44 @@ slider de volumen lleva fallback al rol que envuelve
(`var(--_mp-fg, var(--color-content-primary))`): sin él, el control sale sin
color dentro del panel.
## Pista de subtítulos — `<MediaPlayer.CaptionFloat>` (2026-08-04)
`toggleCaptions()` sólo alcanza la PRIMERA pista, y a propósito: es un toggle, y
el resto se quedan en `disabled` porque **poner una pista en `hidden` obliga al
navegador a descargar su VTT** — encenderlas todas bajaría todos los ficheros de
golpe. La consecuencia es que un vídeo con dos idiomas no tenía ninguna forma de
llegar al segundo.
`<CaptionFloat>` es la lista: `Off` + una entrada por pista, mismo patrón que
`RateFloat` (DropdownMenu + RadioGroup, con el `CaptionButton` real de
disparador y su toggle anulado con `preventDefault()` en la costura). Opt-in: el
botón solo sigue siendo el control correcto para una fuente de una sola pista.
Por debajo, dos añadidos a soma, ninguno al puerto:
- **`selectCaptionTrack(track | null)`** — pone UNA en `hidden` y el resto en
`disabled`, por la misma razón de descarga.
- **`captionTrackList` + `activeCaptionTrack`** — espejos reactivos. El
`TextTrackList` vivo es una colección del DOM y no notifica, así que un chrome
que LISTA las pistas no se enteraba de una que llegase tarde. Los refresca
`syncCaptions`, que ya corría en `addtrack` / `removetrack` / `change`.
⚠️ **Cambiar de idioma NO dispara `commit-toggle-captions`.** Sólo lo hace una
transición real de encendido/apagado: pasar de inglés a español con los
subtítulos ya puestos no es un toggle. Es la misma distinción que el commit de
mute hace con `s.muted !== wasMuted`.
### Por qué esto y no el `SettingsButton`
El engranaje está declarado en el morfo para «velocidad / calidad / pista». La
velocidad tiene lista propia desde hoy, y **la calidad no existe**: cero campos
en `MediaSnapshot`, cero en `MediaProvider`, cero en los espejos de `$arts/sound`
— y con un `<video src>` plano nunca se poblaría, porque los niveles los aporta
un motor HLS/DASH que este framework no vendoriza. Un engranaje con una sola
sección sería este componente disfrazado, así que se queda sin implementar y su
contrato queda escrito en
[`design-media-quality.md`](../../../../docs/decisions/design-media-quality.md).
## Transporte de lista — `RestartButton` + `SkipButton` (2026-08-04)
Dos partes nuevas del morfo (25 en total), **las dos opt-in**: `AudioLayout` no

@ -28,6 +28,7 @@ import VolumeFloat from './media-player-volume-float.svelte';
import RateFloat from './media-player-rate-float.svelte';
import RestartButton from './media-player-restart-button.svelte';
import SkipButton from './media-player-skip-button.svelte';
import CaptionFloat from './media-player-caption-float.svelte';
import DefaultControls from './media-player-default-controls.svelte';
import Artwork from './media-player-artwork.svelte';
import Artist from './media-player-artist.svelte';
@ -57,6 +58,7 @@ type MediaPlayerNamespace = typeof MediaPlayerComponent & {
RateFloat: typeof RateFloat;
RestartButton: typeof RestartButton;
SkipButton: typeof SkipButton;
CaptionFloat: typeof CaptionFloat;
DefaultControls: typeof DefaultControls;
Artwork: typeof Artwork;
Artist: typeof Artist;
@ -87,6 +89,7 @@ MediaPlayer.VolumeFloat = VolumeFloat;
MediaPlayer.RateFloat = RateFloat;
MediaPlayer.RestartButton = RestartButton;
MediaPlayer.SkipButton = SkipButton;
MediaPlayer.CaptionFloat = CaptionFloat;
MediaPlayer.DefaultControls = DefaultControls;
MediaPlayer.Artwork = Artwork;
MediaPlayer.Artist = Artist;
@ -120,6 +123,7 @@ export type {
MediaPlayerRateFloatProps as RateFloatProps,
MediaPlayerRestartButtonProps as RestartButtonProps,
MediaPlayerSkipButtonProps as SkipButtonProps,
MediaPlayerCaptionFloatProps as CaptionFloatProps,
MediaPlayerDefaultControlsProps as DefaultControlsProps,
MediaPlayerArtworkProps as ArtworkProps,
MediaPlayerArtistProps as ArtistProps,

@ -0,0 +1,84 @@
<script lang="ts">
/**
* `<MediaPlayer.CaptionFloat>` — the subtitle TRACK picker, as a floating
* list. Optional: `<CaptionButton>` on its own is still the right control
* for a single-track source, and this is the other one.
*
* Why it exists: `toggleCaptions()` can only ever reach the first track, and
* deliberately so — it is a toggle. A source with several subtitle languages
* had no way to reach the rest at all. The list adds `Off` plus one entry
* per track, which is what the button structurally cannot express.
*
* Why NOT the `SettingsButton`: the gear is declared in the morfo for
* speed / quality / track, but speed now has its own list and quality does
* not exist in the `MediaProvider` port (nothing in `MediaSnapshot` carries
* it — it is an HLS/DASH concept the consumer's engine would supply). A gear
* holding one section would be this component wearing a costume, so the gear
* stays unimplemented until it has two real axes. See the eidos README.
*
* The trigger stays the real `<CaptionButton>`: it keeps its morfo part, its
* `aria-pressed`, the `c` hotkey and the glyph that follows `data-state`,
* plus soma's own disabling when no track exists. Its toggle is suppressed
* here — `composeHandlers` skips the soma handler when the consumer's
* `onclick` calls `preventDefault()` — so the click opens the list.
*
* A radio group, like the speed list: exactly one track is on at a time, so
* the current one is announced as checked instead of merely looking it.
*/
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { MediaPlayerProvider } from '$soma/components/media-player';
import CaptionButton from './media-player-caption-button.svelte';
import type { MediaPlayerCaptionFloatProps } from './types';
let {
side = 'top',
sideOffset = 8,
align = 'center',
...rest
}: MediaPlayerCaptionFloatProps = $props();
const provider = MediaPlayerProvider.require();
const tracks = $derived(provider.captionTrackList);
const off = $derived(provider.soma.langs.ts('#?components.media-player.captions-none|Off'));
// The index IS the identity: `TextTrack.id` comes from the `<track>` element's
// `id` attribute and is empty in practice, and two tracks can share a
// language. `'off'` is the absence.
const value = $derived.by(() => {
const i = tracks.indexOf(provider.activeCaptionTrack as TextTrack);
return i === -1 ? 'off' : String(i);
});
/** `label` is what a `<track>` author wrote; fall back to the language tag. */
const trackName = (t: TextTrack, i: number) => t.label || t.language || `${i + 1}`;
</script>
<DropdownMenu {...rest}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<CaptionButton
{...props}
onclick={(e: MouseEvent) => {
(props as { onclick?: (e: MouseEvent) => void }).onclick?.(e);
// Opening the list IS the click's whole job here.
e.preventDefault();
}}
/>
{/snippet}
</DropdownMenu.Trigger>
<!-- `data-media-player-caption-float` is an eidos-only marker: the panel is
portalled, so the recipe needs something that travels with it to undo the
menu's prose-sized min-width. -->
<DropdownMenu.Content {side} {sideOffset} {align} size="sm" data-media-player-caption-float>
<DropdownMenu.RadioGroup
{value}
onValueChange={(v: string) =>
provider.selectCaptionTrack(v === 'off' ? null : (tracks[Number(v)] ?? null))}
>
<DropdownMenu.RadioItem value="off">{off}</DropdownMenu.RadioItem>
{#each tracks as track, i (i)}
<DropdownMenu.RadioItem value={String(i)}>{trackName(track, i)}</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>

@ -460,7 +460,8 @@
prose («Show notifications»); the widest item here is «1.25×», so the floor
only padded empty space. Shrink to content and keep the indicator gutter,
which is what marks the current speed. */
[data-media-player-rate-float] {
[data-media-player-rate-float],
[data-media-player-caption-float] {
--dropdown-menu-content-min-width: 0;
inline-size: max-content;
}

@ -76,6 +76,19 @@ export type MediaPlayerVolumeFloatProps = PopoverProps & {
/** Gap between the panel and the speaker, in px. @default 10 */
sideOffset?: number;
};
/**
* `<MediaPlayer.CaptionFloat>` — the subtitle TRACK picker. Placement props
* only: the tracks come from the media element, so a chrome cannot list one the
* player does not have.
*/
export type MediaPlayerCaptionFloatProps = DropdownMenuProps & {
/** Which side of the button the list sits on. @default 'top' */
side?: DropdownMenuContentProps['side'];
/** Gap between the list and the button, in px. @default 8 */
sideOffset?: number;
/** Alignment along that side. @default 'center' */
align?: DropdownMenuContentProps['align'];
};
export type MediaPlayerDefaultControlsProps = { id?: string };
// ── Audio-only parts (F5, PLAN-audio-player-v2.md) ───────────────────────────

@ -78,6 +78,12 @@ export const mediaPlayerMorfo = {
volume: '#?components.media-player.volume|Volume',
settings: '#?components.media-player.settings|Settings',
rate: '#?components.media-player.rate|Playback speed',
restart: '#?components.media-player.restart|Restart',
'skip-previous': '#?components.media-player.skip-previous|Previous',
'skip-next': '#?components.media-player.skip-next|Next',
// The «no subtitles» entry of a track LIST — distinct from `captions-off`,
// which names the ACTION of the toggle button.
'captions-none': '#?components.media-player.captions-none|Off',
live: '#?components.media-player.live|Live'
},

@ -82,33 +82,33 @@ interface MediaProvider {
## Parts
| Part | Element | v1 | Description |
| -------------------- | ---------- | --- | ------------------------------------------------------------------------- |
| `Provider` | `<div>` | ✅ | Labeled `region`. Owns the provider, hotkeys, state reflection. |
| `Media` | `<video>` | ✅ | The native element. The `MediaProvider` port attaches here. |
| `Poster` | `<img>` | ✅ | Cover image; hidden once playback starts. `aria-hidden`. |
| `BufferingIndicator` | `<div>` | ✅ | Shown while `data-buffering`. `aria-hidden`. |
| `Controls` | `<div>` | ✅ | Control-bar container. Not a roving Toolbar (see below). |
| `PlayButton` | `<button>` | ✅ | Play/pause. Mirrors `data-paused` → `data-state`. |
| `SeekButton` | `<button>` | ✅ | ±`seekStep` seconds. `direction` prop. |
| `MuteButton` | `<button>` | ✅ | Mute toggle. `aria-pressed` + `data-level` (loudness band). |
| `VolumeSlider` | `<div>` | ✅ | Composes `Slider` (0–1 volume). |
| `TimeSlider` | `<div>` | ✅ | Composes `Slider` + an eidos-only buffered track. |
| `Time` | `<div>` | ✅ | `current` / `duration` / `remaining` readout. |
| `FullscreenButton` | `<button>` | ✅ | Fullscreen toggle. `aria-pressed`. |
| `Title` | `<div>` | ✅ | Optional overlaid title; fades with the scrim. |
| `CaptionButton` | `<button>` | ✅ | Captions toggle (keeps the track `hidden`). Disabled with no track. |
| `PipButton` | `<button>` | ✅ | Picture-in-picture toggle. |
| `Artwork` | `<img>` | ✅ | Album/episode art. Real `alt` — content, not decorative (audio mode). |
| `Artist` | `<div>` | ✅ | Author / series line. |
| `Identity` | `<div>` | ✅ | Groups Artwork + Title + Artist for the layout variants. |
| `Transport` | `<div>` | ✅ | Groups the transport buttons, sibling of Controls. |
| `RateButton` | `<button>` | ✅ | Cycles speed presets (`data-rate`); commit rides `ratechange`. |
| `LiveIndicator` | `<div>` | ✅ | Live badge, `aria-hidden` (state lives on the provider's `data-live`). |
| `Captions` | `<div>` | ✅ | Paints the active cues. Composing it is what turns captions on-screen. |
| `RestartButton` | `<button>` | ✅ | Back to the start of the CURRENT medium (a seek to 0). Opt-in. |
| `SkipButton` | `<button>` | ✅ | Asks for another SOURCE. `direction` prop. Opt-in, inert with no handler. |
| `SettingsButton` | `<button>` | v2 | Opens a settings menu (speed/quality/track) — composes `DropdownMenu`. |
| Part | Element | v1 | Description |
| -------------------- | ---------- | --- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `Provider` | `<div>` | ✅ | Labeled `region`. Owns the provider, hotkeys, state reflection. |
| `Media` | `<video>` | ✅ | The native element. The `MediaProvider` port attaches here. |
| `Poster` | `<img>` | ✅ | Cover image; hidden once playback starts. `aria-hidden`. |
| `BufferingIndicator` | `<div>` | ✅ | Shown while `data-buffering`. `aria-hidden`. |
| `Controls` | `<div>` | ✅ | Control-bar container. Not a roving Toolbar (see below). |
| `PlayButton` | `<button>` | ✅ | Play/pause. Mirrors `data-paused` → `data-state`. |
| `SeekButton` | `<button>` | ✅ | ±`seekStep` seconds. `direction` prop. |
| `MuteButton` | `<button>` | ✅ | Mute toggle. `aria-pressed` + `data-level` (loudness band). |
| `VolumeSlider` | `<div>` | ✅ | Composes `Slider` (0–1 volume). |
| `TimeSlider` | `<div>` | ✅ | Composes `Slider` + an eidos-only buffered track. |
| `Time` | `<div>` | ✅ | `current` / `duration` / `remaining` readout. |
| `FullscreenButton` | `<button>` | ✅ | Fullscreen toggle. `aria-pressed`. |
| `Title` | `<div>` | ✅ | Optional overlaid title; fades with the scrim. |
| `CaptionButton` | `<button>` | ✅ | Captions toggle (keeps the track `hidden`). Disabled with no track. |
| `PipButton` | `<button>` | ✅ | Picture-in-picture toggle. |
| `Artwork` | `<img>` | ✅ | Album/episode art. Real `alt` — content, not decorative (audio mode). |
| `Artist` | `<div>` | ✅ | Author / series line. |
| `Identity` | `<div>` | ✅ | Groups Artwork + Title + Artist for the layout variants. |
| `Transport` | `<div>` | ✅ | Groups the transport buttons, sibling of Controls. |
| `RateButton` | `<button>` | ✅ | Cycles speed presets (`data-rate`); commit rides `ratechange`. |
| `LiveIndicator` | `<div>` | ✅ | Live badge, `aria-hidden` (state lives on the provider's `data-live`). |
| `Captions` | `<div>` | ✅ | Paints the active cues. Composing it is what turns captions on-screen. |
| `RestartButton` | `<button>` | ✅ | Back to the start of the CURRENT medium (a seek to 0). Opt-in. |
| `SkipButton` | `<button>` | ✅ | Asks for another SOURCE. `direction` prop. Opt-in, inert with no handler. |
| `SettingsButton` | `<button>` | v2 | Declared, unimplemented. Speed and track have their own lists now, and quality is not in the port — `docs/decisions/design-media-quality.md`. |
`Title`, `CaptionButton`, `PipButton` and `Captions` are wired; the remaining v2
part (`SettingsButton` menu) is **declared in the morfo contract** (so the shape

@ -656,6 +656,54 @@ describe('MediaPlayerProvider', () => {
dom.dispose();
});
it('picks a subtitle track, which the toggle structurally cannot', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const fake = fakeEngine();
const opts = mediaPlayerOpts(root, () => fake.engine);
const el = document.createElement('video');
const { tracks, modeWrites } = attachCaptionTrack(el, ['English', 'Espanol']);
const { result: provider, cleanup } = withEffectRoot(() => MediaPlayerProvider.create(opts));
const dispose = provider.registerMedia(el);
const trigger = vi.spyOn(provider.runtime, 'trigger');
// The toggle only ever reaches the FIRST track — that is the whole reason
// this method exists.
provider.toggleCaptions();
expect(provider.activeCaptionTrack).toBe(tracks[0]);
trigger.mockClear();
provider.selectCaptionTrack(tracks[1]);
expect(tracks[1].mode).toBe('hidden');
// The others go back to `disabled`: `hidden` makes the UA fetch the VTT,
// so leaving both active would download every file.
expect(tracks[0].mode).toBe('disabled');
expect(provider.activeCaptionTrack).toBe(tracks[1]);
expect(provider.captionsOn).toBe(true);
// Swapping one language for another is NOT a toggle, so it must not fire
// the perceptual commit — captions were on before and are on after.
expect(trigger.mock.calls.flat()).not.toContain('commit-toggle-captions');
// And never `showing`, which is what made the browser paint its own cue
// box over the control bar.
expect(modeWrites).not.toContain('showing');
// `null` is off, and THAT is a real transition.
trigger.mockClear();
provider.selectCaptionTrack(null);
expect(provider.captionsOn).toBe(false);
expect(provider.activeCaptionTrack).toBe(null);
expect(trigger.mock.calls.flat()).toContain('commit-toggle-captions');
// The reactive mirror is what a chrome renders from: the live
// TextTrackList never notifies.
expect(provider.captionTrackList).toHaveLength(2);
dispose();
cleanup();
dom.dispose();
});
it('restarts the medium and asks the app for another source', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
@ -725,13 +773,15 @@ function fragmentText(fragment: DocumentFragment): string {
* the provider to listen, read modes and read `activeCues` exactly as it does
* against a browser.
*/
function attachCaptionTrack(el: HTMLMediaElement) {
function attachCaptionTrack(el: HTMLMediaElement, labels: string[] = ['English']) {
// Every mode WRITE is recorded, not just the final value: the provider also
// demotes `showing` to `hidden` defensively, so asserting the end state
// cannot tell "asked for hidden" from "asked for showing and got caught".
const modeWrites: TextTrackMode[] = [];
class FakeTextTrack extends EventTarget {
kind = 'captions';
label = '';
language = '';
activeCues: unknown[] | null = null;
#mode: TextTrackMode = 'disabled';
get mode(): TextTrackMode {
@ -749,13 +799,22 @@ function attachCaptionTrack(el: HTMLMediaElement) {
}
}
const track = new FakeTextTrack();
const list = new FakeTextTrackList();
list.tracks.push(track);
for (const label of labels) {
const t = new FakeTextTrack();
t.label = label;
list.tracks.push(t);
}
// `track` stays the FIRST one: the single-track callers predate the list.
const track = list.tracks[0];
Object.defineProperty(el, 'textTracks', { value: list, configurable: true });
return {
track,
// Cast at the seam, once: the fake carries exactly the surface the provider
// touches (`mode`, `label`, `activeCues`, the EventTarget), and widening it
// here keeps every call site free of `as unknown as`.
tracks: list.tracks as unknown as TextTrack[],
list,
modeWrites,
setActiveCues(texts: string[]) {

@ -30,6 +30,7 @@ const LANGS = {
restart: '#?components.media-player.restart|Restart',
skipPrevious: '#?components.media-player.skip-previous|Previous',
skipNext: '#?components.media-player.skip-next|Next',
captionsNone: '#?components.media-player.captions-none|Off',
live: '#?components.media-player.live|Live'
} as const;
@ -171,6 +172,16 @@ export class MediaPlayerProvider {
playbackRate = $state(1);
captionsOn = $state(false);
captionsAvailable = $state(false);
/**
* Reactive mirror of {@link captionTracks}. The live `TextTrackList` is a
* DOM collection, so nothing re-renders when a `<track>` arrives late — a
* chrome that LISTS the tracks needs this instead. Refreshed by
* `syncCaptions`, which already runs on `addtrack` / `removetrack` /
* `change`.
*/
captionTrackList = $state<TextTrack[]>([]);
/** The caption track currently on, or `null` when captions are off. */
activeCaptionTrack = $state<TextTrack | null>(null);
/**
* The cues active right now, as WebVTT cue-text fragments — one per cue, in
* track order. The `Captions` part appends them; nothing else reads them.
@ -548,6 +559,28 @@ export class MediaPlayerProvider {
void this.runtime.trigger('commit-toggle-captions');
}
/**
* Put ONE caption track on (or none). `toggleCaptions` can only ever reach
* the first track — deliberately, because it is a toggle — so a player with
* several subtitle languages needs this to reach the rest.
*
* Same `disabled`-for-the-others rule and for the same reason: `hidden`
* makes the user agent fetch the VTT, so leaving every track active would
* download them all.
*/
selectCaptionTrack(track: TextTrack | null): void {
const tracks = this.captionTracks;
if (tracks.length === 0) return;
const wasOn = this.captionsOn;
tracks.forEach((t) => this.setTrackMode(t, t === track ? 'hidden' : 'disabled'));
// Read the modes back rather than assume them, exactly as the toggle does.
this.syncCaptions();
// Only a real on/off transition is a toggle. Swapping one language for
// another while captions stay ON is not — the same distinction the muted
// commit draws with `s.muted !== wasMuted`.
if (this.captionsOn !== wasOn) void this.runtime.trigger('commit-toggle-captions');
}
/** Assign only on a real transition — an unchanged mode fires no `change`. */
private setTrackMode(track: TextTrack, mode: TextTrackMode): void {
if (track.mode !== mode) track.mode = mode;
@ -576,6 +609,10 @@ export class MediaPlayerProvider {
} else if (t.mode === 'hidden') on = true;
}
this.captionsOn = on;
// After the loop: by now every `showing` has been demoted, so the modes
// are settled and `hidden` unambiguously means "this is the one on".
this.captionTrackList = tracks;
this.activeCaptionTrack = tracks.find((t) => t.mode === 'hidden') ?? null;
this.readActiveCues();
}

@ -104,13 +104,23 @@
title ? { title, artwork: poster ? [{ src: poster }] : undefined } : undefined
);
// An inline WebVTT captions track (same-origin data URL) so the CaptionButton
// has something to toggle — a real player receives <track> children.
const CAPTIONS_VTT =
// Inline WebVTT tracks (same-origin data URLs) so the caption controls have
// something real to work on — a real player receives <track> children. TWO
// languages on purpose: the CaptionButton is a toggle and can only ever reach
// the first, so a single track cannot exercise the CaptionFloat's whole point.
const vtt = (first: string, second: string) =>
'data:text/vtt,' +
encodeURIComponent(
'WEBVTT\n\n00:00:00.000 --> 00:00:04.000\nUIX MediaPlayer\n\n00:00:04.000 --> 00:00:20.000\nInline captions demo track\n'
`WEBVTT\n\n00:00:00.000 --> 00:00:04.000\n${first}\n\n00:00:04.000 --> 00:00:20.000\n${second}\n`
);
const CAPTION_TRACKS = [
{ srclang: 'en', label: 'English', src: vtt('UIX MediaPlayer', 'Inline captions demo track') },
{
srclang: 'es',
label: 'Espanol',
src: vtt('UIX MediaPlayer', 'Pista de subtitulos de prueba')
}
];
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
@ -248,7 +258,9 @@
>
<MediaPlayer.Media {src} preload="metadata">
{#if captionsTrack}
<track kind="captions" src={CAPTIONS_VTT} srclang="en" label="English" />
{#each CAPTION_TRACKS as t (t.srclang)}
<track kind="captions" src={t.src} srclang={t.srclang} label={t.label} />
{/each}
{/if}
</MediaPlayer.Media>
{#if showPoster && poster}<MediaPlayer.Poster src={poster} alt="" />{/if}
@ -274,7 +286,7 @@
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.CaptionButton />
<MediaPlayer.CaptionFloat />
<MediaPlayer.PipButton />
<MediaPlayer.FullscreenButton />
</MediaPlayer.Controls>

Loading…
Cancel
Save

Powered by TurnKey Linux.