Theme
Design your own
On this page (11)
Component Skill — components/video/component-skill.md

Native basis

<figure class="video"> around a .video-frame with the native <video> and an optional .video-captions overlay, then an optional .video-controls bar of Buttons, Sliders and readouts.

Web Platform APIs

<video>HTMLMediaElementTextTrackFullscreen APIaria-pressed@container

Classes

.video.video-frame.video-captions.video-controls.video-time.video-volume.video-volume-panel.video-tracks

Data attributes

• data-video-action - toggle, play, pause, skip (+ data-value seconds), seek (a range), mute, volume (a range), rate (a select, or a button with data-value), captions, fullscreen

• data-video-output - time, duration, remaining

• data-video-for="id" - a control (or a container of controls) outside the player drives the player with that id

• <script type="application/json" class="video-tracks"> - the player's own text tracks by key (subtitles, captions, descriptions); two or more turn the captions button into a Dropdown Menu

• .video-volume - the sound button with a vertical volume in a .video-volume-panel above it (data-side="bottom": below)

data-variant="overlay" - the bar over the picture, hidden while it plays; --video-ratio - the frame's ratio (16 / 9)

Accessibility

• Play, mute and captions are toggle buttons: a constant aria-label, aria-pressed for on

• The scrubber is a native range; the runtime writes aria-valuetext ("0:12 of 0:24")

• Captions are text over the picture, on by default where a text track exists

• With two or more text tracks the captions button is a menu button: the Dropdown Menu's keyboard (arrows, Enter, Escape) and radio items

§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 end
  • playing - playing; a browser may refuse to start sound without a click on the page (autoplay rules), and the state then stays default

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:

StateTypeValuesDefaultDescription
playingbooleantrue, falsefalseThe video plays (setState('playing')); unchecked it pauses.
timenumber—0The playback position in seconds - setState(current, { time }) seeks; observed from getState().config.time.
ratenumber—1The playback rate - 1 is normal speed.
volumenumber—1The volume, 0 to 1.
mutedbooleantrue, falsefalseThe sound is off.
captionsstring—""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.

StateDescription
default
Paused - at the start, wherever it was paused, or at the end.
Config fieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe 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
duration?numberthe video's length, seconds - reported by getState() (0 until the metadata loaded), never set
playing
Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default.
Config fieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe 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
duration?numberthe video's length, seconds - reported by getState() (0 until the metadata loaded), never set

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?VideoStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?VideoStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: VideoState; config: VideoStateConfigs[VideoState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.videoApi.store(el: HTMLElement): Store<{ name: VideoState; config: VideoStateConfigs[VideoState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: VideoState; config: VideoStateConfigs[VideoState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?VideoStateConfigs[S]its config
df$.shadcn.videoStates: VideoState[]The declared states, 'default' first: default, playing.

df$.shadcn.video

MemberDescription
play(target: string | HTMLElement): Promise<void>
Start playback (the playing state).
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector

Returns Promise<void> - resolves once playback started, or the browser refused it (the state then reads default)

pause(target: string | HTMLElement): void
Pause (the default state).
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
toggle(target: string | HTMLElement): boolean
Play when paused, pause when playing - what a toggle control does.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector

Returns boolean - true when it plays now

seek(target: string | HTMLElement, time: number): void
Go to a time; playing stays playing.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
timenumberseconds from the start (clamped to the video's length)
skip(target: string | HTMLElement, seconds: number): void
Move by some seconds - forward, or back with a negative number.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
secondsnumberhow far to move
mute(target: string | HTMLElement, muted?: boolean): void
Switch the sound off or on.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
muted?booleantrue for off; default: the opposite of now
volume(target: string | HTMLElement, volume: number): void
Set the volume.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
volumenumber0 to 1
rate(target: string | HTMLElement, rate: number): void
Set the playback rate.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
ratenumber1 is normal speed, 0.5 half, 2 twice as fast
captions(target: string | HTMLElement, key?: string | boolean): void
Show a text track by its key, or none.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
key?string | booleanthe 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
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).
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector
tracksVideoTextTrack[]the tracks, each with its id
replace?booleanalso retire the player's own tracks not listed

Returns string[] - the keys listed

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.
ArgumentTypeDescription
targetstring | HTMLElementthe .video element or its selector

Returns Promise<void> - resolves when the change is done or refused

Types

TypeDescription
VideoConfig
The config both states take - every key optional; only the keys passed are applied.
FieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe 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
duration?numberthe video's length, seconds - reported by getState() (0 until the metadata loaded), never set
VideoCue
One cue of a text track.
FieldTypeDescription
start?numberwhere it starts, seconds
end?numberwhere it ends, seconds
text?stringthe words
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.
FieldTypeDescription
idstringits key, unique on the video: captions "<key>" shows it
kind?stringsubtitles or captions - what is heard (the menu's Subtitles); descriptions - what is seen (its Video captions)
label?stringits name in the captions menu; default: the id
lang?stringits language (BCP 47, "de")
default?booleanshown first when the video shows no other track yet
itemsVideoCue[]its cues

§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