VideoMOL
A video player the page can drive. The native <video> plays it; the component makes its playback a named state - default (paused) or playing, with the time, rate, volume, muted and captions as its config - binds controls inside the player or anywhere on the page, and draws captions in an overlay that also works in full screen. Its text tracks are keyed - its own JSON, a timeline's rows, <track> elements - and with more than one the captions button offers them in a menu: subtitles of what is heard, video captions of what is seen. A Media Timeline drives it from a JSON of timeline metadata.
On this page (11)
§Video
The native video with a bar of Buttons and Sliders: play, back 5 seconds, the scrubber between the time and the length, sound, volume, captions, full screen. The player's own text track (script.video-tracks) describes the picture in German, a line per scene - the captions button turns it off and on, in full screen too. Open the State tab - playing, time, rate, volume, muted and captions (the key of the track shown) are the player's state: change one there, or play the video and watch them follow.
§Overlay controls
data-variant='overlay' lays the bar over the foot of the picture on a dark scrim. While the video plays it hides until a pointer or the focus comes back to the player.
§Native controls
No bar: the video keeps the browser's own controls. The state follows them all the same - play, seek or mute with the native bar and watch the State tab.
§Subtitles and video captions
Four text tracks of the player's own: subtitles of what is heard and video captions of what is seen, each in English and German. With two or more, the captions button opens a Dropdown Menu - Off, then a submenu per group that offers more than one (a group with one track is one item); the choice is the state's captions key. The sound button carries the volume as a vertical slider in a panel above it (.video-volume), shown while the pointer or the focus is there.
§Controls anywhere on the page
A row outside the player names it once with data-video-for: the toggle, the skips, the rate select and the readouts drive and follow the player with that id.
§The API and the store
df$.shadcn.video plays, pauses, seeks and skips by name; el.store reports every change - the line under the buttons is written by a store subscriber, at the media's own pace (about four times a second while it plays).
§A track changes while it plays
df$.shadcn.video.tracks() adds or updates the player's own text tracks by key - also while the video plays: the track shown keeps showing with its new cues, a new key joins the captions menu. Play, then rewrite the notes or add a French track.
§States
Named states via the shared State API, bound on each .video:
default- paused: at the start, wherever it was paused, or at the endplaying- playing; a browser may refuse to start sound without a click on the page (autoplay rules), and the state then staysdefault
Both take the same config, merged into the current one - only the keys passed change something: time (seek), rate, volume, muted, captions (the key of the text track shown, "" for none). getState() reports them live, with the duration.
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/video-{state}.png.
Machine contract - verified against video.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
playing | boolean | true, false | false | The video plays (setState('playing')); unchecked it pauses. |
time | number | — | 0 | The playback position in seconds - setState(current, { time }) seeks; observed from getState().config.time. |
rate | number | — | 1 | The playback rate - 1 is normal speed. |
volume | number | — | 1 | The volume, 0 to 1. |
muted | boolean | true, false | false | The sound is off. |
captions | string | — | "" | The key of the text track the overlay shows (a track's id, a <track>'s id or srclang), empty for none - true and false are accepted. |
§API
Generated from video.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type VideoState = 'default' | 'playing' - setState(name, config) takes the config of the state it names.
| State | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
default | Paused - at the start, wherever it was paused, or at the end.
| |||||||||||||||||||||
playing | Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends VideoState>(name: S, config?: VideoStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: VideoState; config: VideoStateConfigs[VideoState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.videoApi.setState<S extends VideoState>(el: HTMLElement, name: S, config?: VideoStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.videoApi.getState(el: HTMLElement): { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.videoApi.render(state: { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.videoApi.store(el: HTMLElement): Store<{ name: VideoState; config: VideoStateConfigs[VideoState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.videoApi.commit<S extends VideoState>(el: HTMLElement, name: S, config?: VideoStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.videoStates: VideoState[] | The declared states, 'default' first: default, playing. |
df$.shadcn.video
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
play(target: string | HTMLElement): Promise<void> | Start playback (the playing state).
Returns | ||||||||||||
pause(target: string | HTMLElement): void | Pause (the default state).
| ||||||||||||
toggle(target: string | HTMLElement): boolean | Play when paused, pause when playing - what a toggle control does.
Returns | ||||||||||||
seek(target: string | HTMLElement, time: number): void | Go to a time; playing stays playing.
| ||||||||||||
skip(target: string | HTMLElement, seconds: number): void | Move by some seconds - forward, or back with a negative number.
| ||||||||||||
mute(target: string | HTMLElement, muted?: boolean): void | Switch the sound off or on.
| ||||||||||||
volume(target: string | HTMLElement, volume: number): void | Set the volume.
| ||||||||||||
rate(target: string | HTMLElement, rate: number): void | Set the playback rate.
| ||||||||||||
captions(target: string | HTMLElement, key?: string | boolean): void | Show a text track by its key, or none.
| ||||||||||||
tracks(target: string | HTMLElement, tracks: VideoTextTrack[], replace?: boolean): string[] | Add or update the player's own text tracks by key - while it plays too: a track that shows keeps showing with its new cues. The captions menu lists every track of the video (a Media Timeline's text rows and <track> elements as well).
Returns | ||||||||||||
fullscreen(target: string | HTMLElement): Promise<void> | Show the player full screen (controls and captions included), or leave full screen. Browsers allow it only from a click or a key press.
Returns |
Types
| Type | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
VideoConfig | The config both states take - every key optional; only the keys passed are applied.
| |||||||||||||||||||||
VideoCue | One cue of a text track.
| |||||||||||||||||||||
VideoTextTrack | A text track of the player's own - in its script.video-tracks JSON or handed to tracks(). The same shape as a Media Timeline's text rows.
|
§CSS view file
The frame, the captions overlay, the bar, the icon swap of the toggles and the overlay variant.
/* -- Video component ------------------------------------------ *//* A native <video> in a frame of its ratio, a captions overlay *//* the runtime fills from the active cue, and a control bar of *//* Buttons, Sliders and readouts (data-video-action / -output). *//* data-variant="overlay" lays the bar over the picture; it hides *//* while the video plays and nothing points at it. */@layer components { .video { --video-ratio: 16 / 9; position: relative; display: grid; margin: 0; inline-size: 100%; min-inline-size: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background-color: var(--card); color: var(--card-foreground); container-type: inline-size; } .video-frame { position: relative; aspect-ratio: var(--video-ratio); background-color: #000; & > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } } /* the active cue - empty when captions are off or nothing is said */ .video-captions { position: absolute; inset-inline: 0; inset-block-end: 0.75rem; inline-size: fit-content; max-inline-size: 90%; margin-inline: auto; padding: 0.25rem 0.625rem; border-radius: var(--radius-sm); background-color: rgb(0 0 0 / 0.75); color: #fff; font-size: clamp(0.8125rem, 3.4cqi, 1.375rem); line-height: 1.35; text-align: center; white-space: pre-line; text-wrap: balance; pointer-events: none; &:empty { display: none; } } .video-controls { display: flex; align-items: center; gap: 0.25rem; min-inline-size: 0; padding: 0.375rem 0.5rem; border-block-start: 1px solid var(--border); & > [data-video-action="seek"] { flex: 1; min-inline-size: 3rem; } & > [data-video-action="volume"] { inline-size: 4.5rem; } } /* a readout: m:ss in even digits, so the bar does not jitter */ .video-time { min-inline-size: 2.5rem; color: var(--muted-foreground); font-family: var(--font-mono); font-size: 0.75rem; font-variant-numeric: tabular-nums; text-align: center; } /* a toggle with two icons shows the first when off, the second when on (play / pause, sound / muted) */ [data-video-action][aria-pressed="false"] > :nth-child(2), [data-video-action][aria-pressed="true"] > :nth-child(1):not(:only-child) { display: none; } /* a control the page may not use (full screen in a frame without it) - over the Button's own display */ [data-video-action][hidden] { display: none; } /* a rate select keeps its own width in the row (a Select is as wide as its box) */ select[data-video-action="rate"] { flex: none; inline-size: auto; } /* the sound button with a vertical volume in a panel above it (below with data-side="bottom"), shown while the pointer or the focus is on either - the row keeps one button's width */ .video-volume { position: relative; display: inline-flex; } .video-volume-panel { position: absolute; inset-block-end: 100%; inset-inline-start: 50%; z-index: 3; display: grid; place-items: center; margin-block-end: 0.375rem; padding: 0.75rem 0.625rem; border: 1px solid var(--border); border-radius: var(--radius-md); background-color: var(--popover); box-shadow: var(--shadow-md); translate: -50% 0; visibility: hidden; opacity: 0; /* the gap to the button belongs to the hover */ &::after { position: absolute; inset-inline: 0; inset-block-start: 100%; block-size: 0.5rem; content: ""; } /* a vertical range's block axis is horizontal: its length is its height */ & > .slider[data-orientation="vertical"] { height: 5.5rem; } .video-volume:is(:hover, :focus-within) > & { visibility: visible; opacity: 1; } .video-volume[data-side="bottom"] > & { inset-block: 100% auto; margin-block: 0.375rem 0; &::after { inset-block: auto 100%; } } } /* the bar over the picture: a dark scrim, light controls */ .video[data-variant="overlay"] { & > .video-controls { --foreground: #fff; --muted-foreground: rgb(255 255 255 / 0.8); --accent: rgb(255 255 255 / 0.16); --accent-foreground: #fff; --primary: #fff; --primary-foreground: #000; --input: rgb(255 255 255 / 0.35); position: absolute; inset-inline: 0; inset-block-end: 0; padding-block-start: 1.5rem; border-block-start: 0; background-image: linear-gradient(to bottom, transparent, rgb(0 0 0 / 0.72)); color: #fff; } & .video-volume-panel { border-color: rgb(255 255 255 / 0.2); background-color: rgb(0 0 0 / 0.78); } /* the captions sit above the bar while it shows */ &:is(:hover, :focus-within, :not([data-state-name="playing"])) .video-captions { inset-block-end: 3.25rem; } &[data-state-name="playing"]:not(:hover, :focus-within) > .video-controls { opacity: 0; } } /* full screen: the picture takes what the bar leaves */ .video:fullscreen { display: flex; flex-direction: column; border: 0; border-radius: 0; background-color: #000; & > .video-frame { flex: 1; aspect-ratio: auto; } } /* a narrow player keeps play, the scrubber, the time and the sound */ @container (inline-size < 26rem) { .video-controls > [data-video-action="volume"], .video-controls > [data-video-output="duration"], .video-controls > [data-video-action="skip"] { display: none; } } @media (prefers-reduced-motion: no-preference) { .video[data-variant="overlay"] { & > .video-controls { transition: opacity 0.2s ease; } & .video-captions { transition: inset-block-end 0.2s ease; } } .video-volume-panel { transition: opacity 0.15s ease, visibility 0.15s allow-discrete; } } @media (prefers-contrast: more) { .video-captions { background-color: #000; } .video-time { color: var(--foreground); } } @media (forced-colors: active) { .video { border-color: CanvasText; } .video-captions { border: 1px solid CanvasText; background-color: Canvas; color: CanvasText; forced-color-adjust: none; } .video[data-variant="overlay"] > .video-controls { background: Canvas; opacity: 1; } .video-volume-panel { border-color: CanvasText; background-color: Canvas; } }}§JavaScript view file
The states, the API and the init - the media engine itself (reading and driving the element, the controls, the captions) is shared with Audio in core: src/shared/media.ts.
// -- Video ---------------------------------------------------// A stateful video player over the native <video>: the states default (paused) and playing, the playback position,// rate, volume, muted and captions as the state's config, controls inside the player or anywhere on the page// (data-video-for), captions drawn in its own overlay and an imperative API (df$.shadcn.video). The media engine -// reading the element, applying a config, the controls, the captions - is shared with the Audio component// (src/shared/media.ts, emitted once in core); this file keeps the states, the API and the init.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent, bindMedia, mediaApply, mediaMarkup, mediaRead, mediaTracks,} from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const videoStates = ['default', 'playing'];/** The config both states take - every key optional; only the keys passed are applied. */export interface VideoConfig { /** the playback position to seek to, seconds; getState() reports the live position */ time?: number; /** the playback rate - 1 is normal speed, 2 twice as fast */ rate?: number; /** the volume, 0 to 1 */ volume?: number; /** the sound is off */ muted?: boolean; /** the key of the text track to show (its id, a <track>'s id or srclang - see the captions menu), '' for none; * true shows the current or first one, false none. getState() reports the key shown */ captions?: string | boolean; /** the video's length, seconds - reported by getState() (0 until the metadata loaded), never set */ duration?: number;}/** One cue of a text track. */export interface VideoCue { /** where it starts, seconds */ start?: number; /** where it ends, seconds */ end?: number; /** the words */ text?: string;}/** A text track of the player's own - in its script.video-tracks JSON or handed to tracks(). The same shape as a Media * Timeline's text rows. */export interface VideoTextTrack { /** its key, unique on the video: captions "<key>" shows it */ id: string; /** subtitles or captions - what is heard (the menu's Subtitles); descriptions - what is seen (its Video captions) */ kind?: string; /** its name in the captions menu; default: the id */ label?: string; /** its language (BCP 47, "de") */ lang?: string; /** shown first when the video shows no other track yet */ default?: boolean; /** its cues */ items: VideoCue[];}// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state. */export interface VideoStateConfigs { /** Paused - at the start, wherever it was paused, or at the end. */ default: VideoConfig; /** Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default. */ playing: VideoConfig;}/** The markup of a state - the toggles' aria-pressed, the volume and rate controls, the video's muted attribute. */function applyMarkup(el: HTMLElement, stateName: string, config: VideoConfig): void { mediaMarkup(el, 'video', stateName, config);}/** The DOM side of a state: the keys passed are applied to the <video>, then it plays or pauses. */function triggerStateChange( el: HTMLElement, stateName: string, config: VideoConfig, incoming: Record<string, unknown>,) { return mediaApply(el, 'video', stateName, config, incoming);}/** Registry-level API; pass the .video element explicitly. Unknown names throw. */export const videoApi = componentState({ component: 'video', states: videoStates, // the config IS the player's settings: a setState names what changes, the rest stays mergeConfig: true, apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, state.config, incoming), // reflect reality: the native controls, the media keys and the end of the video change it without setState() read: (el) => { const { playing, config } = mediaRead(el); const { time, duration, rate, volume, muted, captions } = config; return { name: playing ? 'playing' : 'default', config: { time, duration, rate, volume, muted, captions } }; }, markup: (el, state) => applyMarkup(el, state.name, state.config),});df$.videoApi = videoApi;df$.videoStates = videoStates;function init() { dfDollar('.video:not([data-init])') .toArray() .forEach((el: HTMLElement) => { el.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores"): $('#my-video').api.setState('playing') bindComponent(el, videoApi); bindMedia(el, 'video', () => videoApi.getState(el)); });}const resolve = (target: string | HTMLElement): HTMLElement | undefined => typeof target === 'string' ? dfDollar(target).get(0) : target;/** the state a call keeps: playing stays playing */const now = (el: HTMLElement): string => (mediaRead(el).playing ? 'playing' : 'default');df$.video = { /** * Start playback (the playing state). * @param target - the .video element or its selector * @returns resolves once playback started, or the browser refused it (the state then reads default) */ play: async (target: string | HTMLElement): Promise<void> => { const el = resolve(target); if (el?.api) await el.api.setState('playing'); }, /** * Pause (the default state). * @param target - the .video element or its selector */ pause: (target: string | HTMLElement): void => { resolve(target)?.api?.setState('default'); }, /** * Play when paused, pause when playing - what a toggle control does. * @param target - the .video element or its selector * @returns true when it plays now */ toggle: (target: string | HTMLElement): boolean => { const el = resolve(target); if (!el?.api) return false; const next = now(el) === 'playing' ? 'default' : 'playing'; el.api.setState(next); return next === 'playing'; }, /** * Go to a time; playing stays playing. * @param target - the .video element or its selector * @param time - seconds from the start (clamped to the video's length) */ seek: (target: string | HTMLElement, time: number): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { time }); }, /** * Move by some seconds - forward, or back with a negative number. * @param target - the .video element or its selector * @param seconds - how far to move */ skip: (target: string | HTMLElement, seconds: number): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { time: mediaRead(el).config.time + seconds }); }, /** * Switch the sound off or on. * @param target - the .video element or its selector * @param muted - true for off; default: the opposite of now */ mute: (target: string | HTMLElement, muted?: boolean): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { muted: muted ?? !mediaRead(el).config.muted }); }, /** * Set the volume. * @param target - the .video element or its selector * @param volume - 0 to 1 */ volume: (target: string | HTMLElement, volume: number): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { volume }); }, /** * Set the playback rate. * @param target - the .video element or its selector * @param rate - 1 is normal speed, 0.5 half, 2 twice as fast */ rate: (target: string | HTMLElement, rate: number): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { rate }); }, /** * Show a text track by its key, or none. * @param target - the .video element or its selector * @param key - the track's key, '' or false for none, true for the current or first one; default: none when one * shows, else the current or first one */ captions: (target: string | HTMLElement, key?: string | boolean): void => { const el = resolve(target); if (el?.api) el.api.setState(now(el), { captions: key ?? (mediaRead(el).config.captions ? '' : true) }); }, /** * Add or update the player's own text tracks by key - while it plays too: a track that shows keeps showing with * its new cues. The captions menu lists every track of the video (a Media Timeline's text rows and <track> * elements as well). * @param target - the .video element or its selector * @param tracks - the tracks, each with its id * @param replace - also retire the player's own tracks not listed * @returns the keys listed */ tracks: (target: string | HTMLElement, tracks: VideoTextTrack[], replace?: boolean): string[] => { const el = resolve(target); return el?.api ? mediaTracks(el, 'video', tracks, replace) : []; }, /** * Show the player full screen (controls and captions included), or leave full screen. Browsers allow it only from * a click or a key press. * @param target - the .video element or its selector * @returns resolves when the change is done or refused */ fullscreen: async (target: string | HTMLElement): Promise<void> => { const el = resolve(target); if (!el) return; if (document.fullscreenElement === el) await document.exitFullscreen().catch(() => undefined); else await el.requestFullscreen().catch(() => undefined); },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub