docs(direction): los charts ya no estan en la cola — la linea llevaba rancia

§10.2 seguia diciendo que la familia chart "no tiene prop ni provider y resuelve
leyendo getComputedStyle(node).direction". Eso lo cerro 43a788083: los cinco que
necesitan direccion declaran dir?: Direction y resuelven por
createChartRtl(eidos, () => dir) -> activeEidosDir. Verificado por grep que no
queda un solo getComputedStyle(...).direction en la familia.

La linea me indujo a repetirlo. El registro historico de §9.20 se queda como
estaba — describe lo que era cierto entonces —; lo corregido es lo que apunta
hacia adelante.

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

@ -101,19 +101,22 @@ 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`). |
| Settings (speed / quality / track) | **diferir** | Requiere componer `DropdownMenu` a nivel soma (los items llaman a `setPlaybackRate`, ya presente). Es el ítem grande restante. |
| 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). |
| 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. |
## Captions — el player pinta las cues (2026-08-04)
@ -213,6 +216,34 @@ 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.
## Transporte de lista — `RestartButton` + `SkipButton` (2026-08-04)
Dos partes nuevas del morfo (25 en total), **las dos opt-in**: `AudioLayout` no
las compone, así que ninguna botonera existente crece un botón.
- **`RestartButton`** vuelve al inicio del medio ACTUAL. Es un seek a 0.
- **`SkipButton`** (`direction='previous'|'next'`) pide otra FUENTE.
⚠️ **El player no tiene cola, y es deliberado.** El `SkipButton` sólo emite: la
raíz recibe `onPrevious` / `onNext` y la app cambia el `src`. Cuando lo hace,
hablan los callbacks de ciclo de vida. Meter una playlist dentro del componente
habría sido un ensanchamiento que nadie pidió, y es lo que hacen Media Chrome y
Vidstack.
De ahí sale una regla útil: **una dirección sin manejador se pinta deshabilitada**
(`data-disabled` + `disabled`). Es el mismo aspecto que los extremos de una cola,
así que la app no tiene que hacer nada especial para el primer y el último
elemento — basta con no pasar el manejador.
**Ninguna de las dos declara evento semántico**, igual que el `SeekButton` que ya
existía: moverse por el material es navegación, no consecuencia. Lo que suena es
el resultado (arranque, final, error), que ya estaba cubierto. Decisión del
usuario, 2026-08-04.
Los glifos de salto **se voltean en RTL** con los de seek (previous/next leen
como atrás/adelante en el mismo eje); el de reinicio no, porque su flecha
circular habla de volver, no de una dirección de la fila.
## Velocidad flotante — `<MediaPlayer.RateFloat>` (2026-08-04)
`<RateButton>` sólo **sube**: su `onclick` es `cycleRate(1)` y el paso recorta

@ -26,6 +26,8 @@ import TimeSlider from './media-player-time-slider.svelte';
import VolumeSlider from './media-player-volume-slider.svelte';
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 DefaultControls from './media-player-default-controls.svelte';
import Artwork from './media-player-artwork.svelte';
import Artist from './media-player-artist.svelte';
@ -53,6 +55,8 @@ type MediaPlayerNamespace = typeof MediaPlayerComponent & {
VolumeSlider: typeof VolumeSlider;
VolumeFloat: typeof VolumeFloat;
RateFloat: typeof RateFloat;
RestartButton: typeof RestartButton;
SkipButton: typeof SkipButton;
DefaultControls: typeof DefaultControls;
Artwork: typeof Artwork;
Artist: typeof Artist;
@ -81,6 +85,8 @@ MediaPlayer.TimeSlider = TimeSlider;
MediaPlayer.VolumeSlider = VolumeSlider;
MediaPlayer.VolumeFloat = VolumeFloat;
MediaPlayer.RateFloat = RateFloat;
MediaPlayer.RestartButton = RestartButton;
MediaPlayer.SkipButton = SkipButton;
MediaPlayer.DefaultControls = DefaultControls;
MediaPlayer.Artwork = Artwork;
MediaPlayer.Artist = Artist;
@ -112,6 +118,8 @@ export type {
MediaPlayerVolumeSliderProps as VolumeSliderProps,
MediaPlayerVolumeFloatProps as VolumeFloatProps,
MediaPlayerRateFloatProps as RateFloatProps,
MediaPlayerRestartButtonProps as RestartButtonProps,
MediaPlayerSkipButtonProps as SkipButtonProps,
MediaPlayerDefaultControlsProps as DefaultControlsProps,
MediaPlayerArtworkProps as ArtworkProps,
MediaPlayerArtistProps as ArtistProps,

@ -0,0 +1,35 @@
<script lang="ts">
/**
* Eidos `<MediaPlayer.RestartButton>` — composes `<IconButton>` (ghost) via
* soma's `child`; size from the player's eidos context. Opt-in: `AudioLayout`
* does not compose it, so no existing bar grows a button.
*/
import * as MediaPlayer from '$soma/components/media-player';
import { IconButton } from '$uix/eidos/components/icon-button';
import { RotateCcw } from '$uix/eidos/components/icon';
import { getMediaPlayerEidosCtx } from './context';
import type { MediaPlayerRestartButtonProps } from './types';
let { children, child: outerChild, ...rest }: MediaPlayerRestartButtonProps = $props();
const ctx = getMediaPlayerEidosCtx();
const size = $derived(ctx?.size ?? 'md');
</script>
<MediaPlayer.RestartButton {...rest}>
{#snippet child({ props })}
{@const p = props as Record<string, unknown>}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton
{...props}
variant="ghost"
color="neutral"
{size}
aria-label={p['aria-label'] as string}
>
{#if children}{@render children()}{:else}<RotateCcw {size} />{/if}
</IconButton>
{/if}
{/snippet}
</MediaPlayer.RestartButton>

@ -0,0 +1,43 @@
<script lang="ts">
/**
* Eidos `<MediaPlayer.SkipButton>` — composes `<IconButton>` (ghost) via
* soma's `child`; size from the player's eidos context. SkipBack / SkipForward
* glyph by `direction`, mirrored under RTL by the recipe like the other
* directional glyphs.
*/
import * as MediaPlayer from '$soma/components/media-player';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SkipBack, SkipForward } from '$uix/eidos/components/icon';
import { getMediaPlayerEidosCtx } from './context';
import type { MediaPlayerSkipButtonProps } from './types';
let {
direction = 'next',
children,
child: outerChild,
...rest
}: MediaPlayerSkipButtonProps = $props();
const ctx = getMediaPlayerEidosCtx();
const size = $derived(ctx?.size ?? 'md');
</script>
<MediaPlayer.SkipButton {direction} {...rest}>
{#snippet child({ props })}
{@const p = props as Record<string, unknown>}
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton
{...props}
variant="ghost"
color="neutral"
{size}
aria-label={p['aria-label'] as string}
>
{#if children}{@render children()}{:else if direction === 'previous'}<SkipBack
{size}
/>{:else}<SkipForward {size} />{/if}
</IconButton>
{/if}
{/snippet}
</MediaPlayer.SkipButton>

@ -296,6 +296,10 @@
whether the captions glyph reads as directional is a call the catalogue has
not made. */
[data-media-player][data-dir='rtl'] [data-media-player-seek-button] svg,
/* The skip glyphs point along the SAME axis: previous/next in a queue read as
back/forward in time, so they mirror with the seek arrows. Restart does not —
its circular arrow is about returning, not about a direction on the row. */
[data-media-player][data-dir='rtl'] [data-media-player-skip-button] svg,
[data-media-player][data-dir='rtl'] [data-media-player-play-button] svg,
[data-media-player][data-dir='rtl'] [data-media-player-mute-button] svg,
[data-media-player][data-dir='rtl'] [data-media-player-pip-button] svg {

@ -8,6 +8,8 @@ import type {
ControlsProps,
PlayButtonProps,
SeekButtonProps,
RestartButtonProps,
SkipButtonProps,
MuteButtonProps,
FullscreenButtonProps,
PipButtonProps,
@ -47,6 +49,8 @@ export type MediaPlayerBufferingIndicatorProps = BufferingIndicatorProps;
export type MediaPlayerControlsProps = ControlsProps;
export type MediaPlayerPlayButtonProps = PlayButtonProps;
export type MediaPlayerSeekButtonProps = SeekButtonProps;
export type MediaPlayerRestartButtonProps = RestartButtonProps;
export type MediaPlayerSkipButtonProps = SkipButtonProps;
export type MediaPlayerMuteButtonProps = MuteButtonProps;
export type MediaPlayerFullscreenButtonProps = FullscreenButtonProps;
export type MediaPlayerPipButtonProps = PipButtonProps;

@ -25,6 +25,8 @@ describe('mediaPlayerMorfo', () => {
'title',
'controls',
'play-button',
'restart-button',
'skip-button',
'seek-button',
'mute-button',
'volume-slider',

@ -376,6 +376,50 @@ export const mediaPlayerMorfo = {
{ attr: 'aria-keyshortcuts', value: v.literal('k') }
]
},
{
// Back to the start of the CURRENT medium — a seek to 0, not a change
// of source. Like the SeekButton it declares no event of its own: the
// player already retired `commit-set-time` (D-AP2.13), and moving
// within the same medium is navigation, not a consequence.
name: 'RestartButton',
kebab: 'restart-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.propRef('label'), severity: 'recommended' }
]
},
{
// Previous / next SOURCE. The player owns no queue — it emits the
// request and the app swaps the `src`; the lifecycle callbacks then
// speak for the new medium. `data-disabled` is how an app marks the
// ends of its queue. Same shape as the SeekButton, one declaration
// instantiated both ways, because the pair is symmetric.
name: 'SkipButton',
kebab: 'skip-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [
{
attr: 'data-direction',
values: ['previous', 'next'],
value: v.propRef('direction')
},
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.propRef('label'), severity: 'recommended' }
]
},
{
// One declaration, instantiated forward + backward via `direction`.
name: 'SeekButton',

@ -67,7 +67,7 @@ interface MediaProvider {
| ---------------- | ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `media` | `'video' \| 'audio'` | `'video'` | Which native surface to drive + the layout archetype. |
| `seekStep` | `number` | `10` | Seconds the ± seek buttons and arrow keys move. |
| `dir` | `Direction` | prefs | Reading direction. Flips the seek hotkeys, reaches the embedded Sliders, and the recipe re-aims the directional glyphs. [Contract](../../../../../docs/canon/direction-contract.md). |
| `dir` | `Direction` | prefs | Reading direction. Flips the seek hotkeys, reaches the embedded Sliders, and the recipe re-aims the directional glyphs. [Contract](../../../../../docs/canon/direction-contract.md). |
| `createProvider` | `(el) => MediaProvider` | sound service | Pluggable engine port (see above). |
| `metadata` | `SoundMediaMetadata` | — | Source identity → OS media session (opt-in, D-AP2.5). Captured when the media element registers: a live change does not re-project (a new track changes `src` / remounts; queues are out of scope, D-AP2.6). |
| `ariaLabel` | `string` | localized `label` | Accessible name for the player `region`. |

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { MediaPlayerRestartButtonProvider } from '../media-player-provider.svelte';
import type { MediaPlayerRestartButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'media-player-restart-button'),
disabled = false,
children,
child,
...restProps
}: MediaPlayerRestartButtonProps = $props();
const state = MediaPlayerRestartButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { MediaPlayerSkipButtonProvider } from '../media-player-provider.svelte';
import type { MediaPlayerSkipButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'media-player-skip-button'),
direction = 'next',
disabled = false,
children,
child,
...restProps
}: MediaPlayerSkipButtonProps = $props();
const state = MediaPlayerSkipButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
direction: readableActive(() => direction),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -23,6 +23,8 @@
onPause,
onEnded,
onError,
onPrevious,
onNext,
children,
child,
...restProps
@ -43,6 +45,8 @@
onPause: readableActive(() => onPause),
onEnded: readableActive(() => onEnded),
onError: readableActive(() => onError),
onPrevious: readableActive(() => onPrevious),
onNext: readableActive(() => onNext),
// svelte-ignore state_referenced_locally
createProvider
});

@ -5,6 +5,8 @@ export { default as BufferingIndicator } from './components/media-player-bufferi
export { default as Controls } from './components/media-player-controls.svelte';
export { default as PlayButton } from './components/media-player-play-button.svelte';
export { default as SeekButton } from './components/media-player-seek-button.svelte';
export { default as RestartButton } from './components/media-player-restart-button.svelte';
export { default as SkipButton } from './components/media-player-skip-button.svelte';
export { default as MuteButton } from './components/media-player-mute-button.svelte';
export { default as FullscreenButton } from './components/media-player-fullscreen-button.svelte';
export { default as PipButton } from './components/media-player-pip-button.svelte';
@ -39,6 +41,8 @@ export type {
MediaPlayerControlsProps as ControlsProps,
MediaPlayerPlayButtonProps as PlayButtonProps,
MediaPlayerSeekButtonProps as SeekButtonProps,
MediaPlayerRestartButtonProps as RestartButtonProps,
MediaPlayerSkipButtonProps as SkipButtonProps,
MediaPlayerMuteButtonProps as MuteButtonProps,
MediaPlayerFullscreenButtonProps as FullscreenButtonProps,
MediaPlayerPipButtonProps as PipButtonProps,

@ -14,7 +14,9 @@ import {
MediaPlayerProvider,
MediaPlayerPlayButtonProvider,
MediaPlayerMuteButtonProvider,
MediaPlayerTimeProvider
MediaPlayerTimeProvider,
MediaPlayerRestartButtonProvider,
MediaPlayerSkipButtonProvider
} from './media-player-provider.svelte';
import type { MediaEventType, MediaProvider, MediaSnapshot } from './media-provider';
import { createEngineSound } from '$sound';
@ -653,6 +655,63 @@ describe('MediaPlayerProvider', () => {
cleanup();
dom.dispose();
});
it('restarts the medium and asks the app for another source', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const fake = fakeEngine({ currentTime: 42 });
const onNext = vi.fn();
const opts = {
...mediaPlayerOpts(root, () => fake.engine),
// Only ONE direction has a handler: the other button must read as
// unavailable, which is how the ends of a queue render too.
onNext: state<(() => void) | undefined>(onNext),
onPrevious: state<(() => void) | undefined>(undefined)
};
const {
result: { restart, next, previous },
cleanup
} = withEffectRoot(() => {
const p = MediaPlayerProvider.create(opts);
vi.spyOn(MediaPlayerProvider, 'require').mockReturnValue(p);
p.registerMedia(document.createElement('video'));
return {
restart: MediaPlayerRestartButtonProvider.create({
id: state('restart'),
ref: state<HTMLElement | null>(null),
disabled: state(false)
}),
next: MediaPlayerSkipButtonProvider.create({
id: state('next'),
ref: state<HTMLElement | null>(null),
direction: state<'previous' | 'next'>('next'),
disabled: state(false)
}),
previous: MediaPlayerSkipButtonProvider.create({
id: state('previous'),
ref: state<HTMLElement | null>(null),
direction: state<'previous' | 'next'>('previous'),
disabled: state(false)
})
};
});
restart.props.onclick();
expect(fake.engine.seek).toHaveBeenLastCalledWith(0);
next.props.onclick();
expect(onNext).toHaveBeenCalledOnce();
expect(next.props['data-disabled']).toBeUndefined();
// No handler for `previous` → inert, and it says so in the DOM.
previous.props.onclick();
expect(previous.props['data-disabled']).toBe('');
expect(previous.props.disabled).toBe(true);
cleanup();
dom.dispose();
});
});
/** Text of a cue fragment, for asserting without leaning on the DOM shape. */

@ -27,6 +27,9 @@ const LANGS = {
captionsOff: '#?components.media-player.captions-off|Disable captions',
settings: '#?components.media-player.settings|Settings',
rate: '#?components.media-player.rate|Playback speed',
restart: '#?components.media-player.restart|Restart',
skipPrevious: '#?components.media-player.skip-previous|Previous',
skipNext: '#?components.media-player.skip-next|Next',
live: '#?components.media-player.live|Live'
} as const;
@ -76,6 +79,13 @@ interface MediaPlayerOpts
onPause?: Active<(() => void) | undefined>;
onEnded?: Active<(() => void) | undefined>;
onError?: Active<(() => void) | undefined>;
/**
* Requests for a DIFFERENT source. The player owns no queue, so it only
* emits: the app swaps the `src`. Absent handler = the SkipButton in that
* direction renders disabled, which is also how the ends of a queue read.
*/
onPrevious?: Active<(() => void) | undefined>;
onNext?: Active<(() => void) | undefined>;
}
/**
@ -1024,6 +1034,101 @@ export class MediaPlayerSeekButtonProvider {
);
}
// ── Restart / skip (transport over the SOURCE, not the position) ─────────────
interface MediaPlayerRestartButtonOpts extends WithRefOpts, ActiveProps<{ disabled: boolean }> {}
/** Back to the start of the current medium. A seek to 0 — nothing sounds. */
export class MediaPlayerRestartButtonProvider {
readonly opts: MediaPlayerRestartButtonOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MediaPlayerProvider;
static create(opts: MediaPlayerRestartButtonOpts) {
return new MediaPlayerRestartButtonProvider(opts);
}
private constructor(opts: MediaPlayerRestartButtonOpts) {
this.opts = opts;
this.provider = MediaPlayerProvider.require();
this.runtimePart = this.provider.runtime.part('restart-button', {
id: opts.id,
ref: opts.ref,
owner: this,
props: { label: () => this.label }
});
}
readonly label = $derived.by(() => this.provider.soma.langs.ts(LANGS.restart));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.renderProps(),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
disabled: this.opts.disabled.current || undefined,
// Seeking to 0 also clears the skip flag through `seek` — a restart is
// not a jump to the end, so the next natural end still completes.
onclick: () => this.provider.seek(0)
} as const)
);
}
interface MediaPlayerSkipButtonOpts
extends WithRefOpts, ActiveProps<{ direction: 'previous' | 'next'; disabled: boolean }> {}
/**
* Previous / next SOURCE. The player owns no queue: the click calls the root's
* `onPrevious` / `onNext` and the app swaps the `src`. Without a handler the
* button is inert by construction — nothing to disable, nothing to pretend.
*/
export class MediaPlayerSkipButtonProvider {
readonly opts: MediaPlayerSkipButtonOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MediaPlayerProvider;
static create(opts: MediaPlayerSkipButtonOpts) {
return new MediaPlayerSkipButtonProvider(opts);
}
private constructor(opts: MediaPlayerSkipButtonOpts) {
this.opts = opts;
this.provider = MediaPlayerProvider.require();
this.runtimePart = this.provider.runtime.part('skip-button', {
id: opts.id,
ref: opts.ref,
owner: this,
props: {
label: () => this.label,
direction: () => this.opts.direction.current
}
});
}
readonly label = $derived.by(() =>
this.provider.soma.langs.ts(
this.opts.direction.current === 'next' ? LANGS.skipNext : LANGS.skipPrevious
)
);
/** No handler for this direction → the button reads as disabled. */
readonly unavailable = $derived.by(() => {
const root = this.provider.opts;
const handler = this.opts.direction.current === 'next' ? root.onNext : root.onPrevious;
return this.opts.disabled.current || !handler?.current;
});
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.renderProps(),
'data-disabled': boolToEmptyStrOrUndef(this.unavailable),
disabled: this.unavailable || undefined,
onclick: () => {
const root = this.provider.opts;
if (this.opts.direction.current === 'next') root.onNext?.current?.();
else root.onPrevious?.current?.();
}
} as const)
);
}
// ── Mute button ───────────────────────────────────────────────────────────────
interface MediaPlayerMuteButtonOpts extends WithRefOpts {}

@ -73,6 +73,13 @@ export type MediaPlayerProps = WithChild<{
*/
onEnded?: () => void;
onError?: () => void;
/**
* Requests for a DIFFERENT source, from the SkipButtons. The player owns no
* queue: it only asks, and the app swaps the `src`. A direction with no
* handler renders its button disabled.
*/
onPrevious?: () => void;
onNext?: () => void;
}> &
Without<PrimitiveDivAttributes, {}>;
@ -99,6 +106,28 @@ export type MediaPlayerControlsProps = WithChild<{ id?: string }> &
export type MediaPlayerPlayButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type MediaPlayerRestartButtonProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Renders inert. @default false */
disabled?: boolean;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type MediaPlayerSkipButtonProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Which source to ask for. @default 'next' */
direction?: 'previous' | 'next';
/**
* Renders inert. The button is ALSO inert when the root has no handler for
* this direction — an app marks the ends of its queue either way.
* @default false
*/
disabled?: boolean;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type MediaPlayerSeekButtonProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;

@ -244,6 +244,7 @@
metadata={videoMetadata}
ariaLabel={ariaLabel || undefined}
{...lifecycleProps}
onNext={() => (sample = sample === 'bunny' ? 'sintel' : 'bunny')}
>
<MediaPlayer.Media {src} preload="metadata">
{#if captionsTrack}
@ -258,9 +259,12 @@
<MediaPlayer.DefaultControls />
{:else}
<MediaPlayer.Controls>
<MediaPlayer.SkipButton direction="previous" />
<MediaPlayer.RestartButton />
<MediaPlayer.PlayButton />
<MediaPlayer.SeekButton direction="backward" />
<MediaPlayer.SeekButton direction="forward" />
<MediaPlayer.SkipButton direction="next" />
{#if volume === 'float'}
<MediaPlayer.VolumeFloat />
{:else}

Loading…
Cancel
Save

Powered by TurnKey Linux.