AudioMOL
An audio player the page can drive. The native <audio> 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 - in one row of Buttons, Sliders and readouts, binds controls anywhere on the page and shows the active caption as a transcript line. It runs on the same media engine as Video, and a Media Timeline drives it the same way.
On this page (9)
§Audio
One row: play, the title over the scrubber and its readouts, sound and volume. Open the State tab - playing, time, rate, volume, muted and captions are the player's state.
§Compact
data-variant='compact' drops the frame and the title - the controls alone, for a row in a list or a line of text. The sound button carries the volume as a vertical slider (.audio-volume): its panel opens below the row (data-side='bottom') while the pointer or the focus is on it, so the row stays one line.
§Rate and skip
A select of rates and two skip buttons inside the row - the select shows the rate that plays, whoever set it.
§Controls anywhere on the page
A row outside the player names it once with data-audio-for: its toggle and its readout drive and follow the player. The three buttons call df$.shadcn.audio - each jumps to a sound of the recording and plays from there.
§The API and the store
df$.shadcn.audio plays, pauses, seeks and mutes by name; el.store reports every change - the line under the buttons is written by a store subscriber.
§States
Named states via the shared State API, bound on each .audio:
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}/audio-{state}.png.
Machine contract - verified against audio.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
playing | boolean | true, false | false | The audio 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 transcript line shows (a track's id, a <track>'s id or srclang), empty for none - true and false are accepted. |
§API
Generated from audio.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type AudioState = '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 AudioState>(name: S, config?: AudioStateConfigs[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: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState] }> | 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.audioApi.setState<S extends AudioState>(el: HTMLElement, name: S, config?: AudioStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.audioApi.getState(el: HTMLElement): { name: AudioState; config: AudioStateConfigs[AudioState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.audioApi.render(state: { name: AudioState; config: AudioStateConfigs[AudioState]; 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.audioApi.store(el: HTMLElement): Store<{ name: AudioState; config: AudioStateConfigs[AudioState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.audioApi.commit<S extends AudioState>(el: HTMLElement, name: S, config?: AudioStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.audioStates: AudioState[] | The declared states, 'default' first: default, playing. |
df$.shadcn.audio
| 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: AudioTextTrack[], 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 audio (a Media Timeline's text rows and <track> elements as well).
Returns |
Types
| Type | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
AudioConfig | The config both states take - every key optional; only the keys passed are applied.
| |||||||||||||||||||||
AudioCue | One cue of a text track.
| |||||||||||||||||||||
AudioTextTrack | A text track of the player's own - in its script.audio-tracks JSON or handed to tracks(). The same shape as a Media Timeline's text rows.
|
§CSS view file
The row, the body and its scrubber, the transcript line, the icon swap of the toggles and the compact variant.
/* -- Audio component ------------------------------------------ *//* A native <audio> (no controls of its own) behind one row: the *//* play toggle, a title over the scrubber and its readouts, the *//* sound controls - Buttons, Sliders and readouts *//* (data-audio-action / -output). An optional line under the *//* scrubber shows the active caption cue, a transcript as it is *//* spoken. data-variant="compact" drops the frame and the title. */@layer components { .audio { display: flex; align-items: center; gap: 0.5rem; inline-size: 100%; min-inline-size: 0; padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background-color: var(--card); color: var(--card-foreground); container-type: inline-size; & > audio:not([controls]) { display: none; } & > [data-audio-action="volume"] { inline-size: 4.5rem; } } .audio-body { display: grid; flex: 1; gap: 0.125rem; min-inline-size: 0; } .audio-title { overflow: hidden; font-size: 0.875rem; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } .audio-progress { display: flex; align-items: center; gap: 0.5rem; min-inline-size: 0; & > [data-audio-action="seek"] { flex: 1; min-inline-size: 3rem; } } /* a readout: m:ss in even digits, so the row does not jitter */ .audio-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; } /* the active cue - collapses when captions are off or nothing is said */ .audio-captions { margin: 0; color: var(--muted-foreground); font-size: 0.8125rem; font-style: italic; text-wrap: pretty; &:empty { display: none; } } /* a toggle with two icons shows the first when off, the second when on (play / pause, sound / muted) */ [data-audio-action][aria-pressed="false"] > :nth-child(2), [data-audio-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-audio-action][hidden] { display: none; } /* a rate select keeps its own width in the row (a Select is as wide as its box) */ select[data-audio-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 */ .audio-volume { position: relative; display: inline-flex; } .audio-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; } .audio-volume:is(:hover, :focus-within) > & { visibility: visible; opacity: 1; } .audio-volume[data-side="bottom"] > & { inset-block: 100% auto; margin-block: 0.375rem 0; &::after { inset-block: auto 100%; } } } .audio[data-variant="compact"] { padding: 0; border: 0; background-color: transparent; & .audio-title { display: none; } } /* a narrow player keeps play, the scrubber, the time and the sound */ @container (inline-size < 22rem) { .audio > [data-audio-action="volume"], .audio > [data-audio-action="rate"], .audio-progress > [data-audio-output="duration"] { display: none; } } @media (prefers-reduced-motion: no-preference) { .audio-volume-panel { transition: opacity 0.15s ease, visibility 0.15s allow-discrete; } } @media (prefers-contrast: more) { .audio-time, .audio-captions { color: var(--foreground); } } @media (forced-colors: active) { .audio, .audio-volume-panel { border-color: CanvasText; } .audio-volume-panel { background-color: Canvas; } }}§JavaScript view file
The states, the API and the init - the media engine itself is shared with Video in core: src/shared/media.ts.
// -- Audio ---------------------------------------------------// A stateful audio player over the native <audio>: 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-audio-for), the active caption cue as a transcript line and an imperative API (df$.shadcn.audio). The media// engine - reading the element, applying a config, the controls, the captions - is shared with the Video 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 audioStates = ['default', 'playing'];/** The config both states take - every key optional; only the keys passed are applied. */export interface AudioConfig { /** 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 audio's length, seconds - reported by getState() (0 until the metadata loaded), never set */ duration?: number;}/** One cue of a text track. */export interface AudioCue { /** 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.audio-tracks JSON or handed to tracks(). The same shape as a Media * Timeline's text rows. */export interface AudioTextTrack { /** its key, unique on the audio: 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 audio shows no other track yet */ default?: boolean; /** its cues */ items: AudioCue[];}// 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 AudioStateConfigs { /** Paused - at the start, wherever it was paused, or at the end. */ default: AudioConfig; /** Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default. */ playing: AudioConfig;}/** The markup of a state - the toggles' aria-pressed, the volume and rate controls, the audio's muted attribute. */function applyMarkup(el: HTMLElement, stateName: string, config: AudioConfig): void { mediaMarkup(el, 'audio', stateName, config);}/** The DOM side of a state: the keys passed are applied to the <audio>, then it plays or pauses. */function triggerStateChange( el: HTMLElement, stateName: string, config: AudioConfig, incoming: Record<string, unknown>,) { return mediaApply(el, 'audio', stateName, config, incoming);}/** Registry-level API; pass the .audio element explicitly. Unknown names throw. */export const audioApi = componentState({ component: 'audio', states: audioStates, // 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 audio 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$.audioApi = audioApi;df$.audioStates = audioStates;function init() { dfDollar('.audio:not([data-init])') .toArray() .forEach((el: HTMLElement) => { el.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores"): $('#my-audio').api.setState('playing') bindComponent(el, audioApi); bindMedia(el, 'audio', () => audioApi.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$.audio = { /** * Start playback (the playing state). * @param target - the .audio 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 .audio 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 .audio 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 .audio element or its selector * @param time - seconds from the start (clamped to the audio'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 .audio 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 .audio 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 .audio 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 .audio 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 .audio 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 audio (a Media Timeline's text rows and <track> * elements as well). * @param target - the .audio 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: AudioTextTrack[], replace?: boolean): string[] => { const el = resolve(target); return el?.api ? mediaTracks(el, 'audio', tracks, replace) : []; },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub