useMediaElement
A media element's state — time, duration, buffered ranges, volume, rate, loading and the error that stopped it — and the controls that drive it.
The one place the kit reads an <audio> or a <video>, so the video player and the audio player cannot drift apart. The element is the truth: a control asks it, and the state changes when the element's own events say it did — play, pause, timeupdate, durationchange, progress, volumechange, ratechange — with one exception, a seek, which moves the reading at once so a scrubber does not jump back while the browser seeks. status is "loading" while a reader would be waiting (the first data, or more of it mid-play: canplay ends it, waiting starts it again), "ready" when nothing is on its way — including a paused element the browser chose not to fetch, preload="none" — and "error" when the file cannot be played. error names what failed. A 404 and a format the browser cannot decode both arrive as MEDIA_ERR_SRC_NOT_SUPPORTED with no source left, so the types the element's <source>s declare decide: if the browser can play one of them the file is "missing", if it can play none the format is "unsupported", and with no type declared — a bare src — there is no telling, "unplayable": not there, or not playable here. A file that would not decode is "unsupported" too, a dropped connection "network", a stopped load "aborted". A list of <source> elements reports its failure on the last source rather than on the element, which the hook hears too. A page that hydrates after its file has already loaded, failed or been held back is read as it stands, not left waiting for an event that has gone by. play() never rejects: a browser that refuses to start without a gesture leaves the element paused and the state says so. The ref may be filled late or swapped for another element; the hook follows whichever one it holds after each render. formatMediaTime() prints the times every player shows — 0:05, 1:02, 1:02:03 — and reads anything that is not a time yet as 0:00. The item also ships media-seek.ts, what the two players' seek bars share so they cannot drift apart: the speeds, the keys (five seconds an arrow, in reading order; a tenth a page; Home and End; never a key held with Ctrl, Meta or Alt), where a pointer lands in reading order, the slider's aria-value words, and the pointer half of a seek bar.
Install
npx shadcn@latest add @vibra/use-media-elementNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { PauseIcon, PlayIcon, Volume2Icon, VolumeXIcon } from "lucide-react"
import { formatMediaTime, useMediaElement } from "@/hooks/use-media-element"
import { Button } from "@/components/ui/button"
import { DescriptionList } from "@/components/ui/description-list"
// The kit's own sample: fifteen seconds made in the browser, served by the site.
const TUNE = "/media/samples/sample-tune.m4a"
const READS: Record<string, string> = {
missing: "the file is not there",
unsupported: "this browser cannot play its format",
unplayable: "the file is not there, or its format cannot be played here",
network: "the connection dropped",
aborted: "the load was stopped",
}
/** A control of your own on the hook: the buttons ask the element, the readout is what it answers. */
export default function UseMediaElementDemo() {
const ref = React.useRef<HTMLAudioElement>(null)
const media = useMediaElement(ref)
const unavailable = media.status === "error"
const held = media.buffered.reduce((sum, range) => sum + (range.end - range.start), 0)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<audio ref={ref} src={TUNE} preload="metadata" />
<div className="flex flex-wrap items-center gap-2">
<Button size="icon" aria-label={media.paused ? "Play" : "Pause"} disabled={unavailable} onClick={media.toggle}>
{media.paused ? <PlayIcon /> : <PauseIcon />}
</Button>
<Button variant="outline" size="sm" disabled={unavailable} onClick={() => media.seek(media.currentTime - 5)}>
Back 5 s
</Button>
<Button variant="outline" size="sm" disabled={unavailable} onClick={() => media.seek(media.currentTime + 5)}>
On 5 s
</Button>
<Button
variant="outline"
size="sm"
aria-pressed={media.playbackRate === 2}
disabled={unavailable}
onClick={() => media.setPlaybackRate(media.playbackRate === 2 ? 1 : 2)}
>
2×
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={media.muted ? "Unmute" : "Mute"}
disabled={unavailable}
onClick={() => media.setMuted(!media.muted)}
>
{media.muted ? <VolumeXIcon /> : <Volume2Icon />}
</Button>
</div>
<DescriptionList
size="sm"
items={[
{ term: "Status", description: unavailable ? `Error: ${READS[media.error ?? "unsupported"]}` : media.status === "ready" ? "Ready" : "Loading" },
{
term: "Time",
description: (
// A run of its own, left to right: a right-to-left page would turn "0:05 of 0:15" round.
<bdi dir="ltr" className="tabular-nums">
{formatMediaTime(media.currentTime)} of {formatMediaTime(media.duration)}
</bdi>
),
},
{ term: "Buffered", description: <span className="tabular-nums">{Math.round(held * 100)}%</span> },
{ term: "Rate", description: <span className="tabular-nums">{media.playbackRate}×</span> },
]}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ref | React.RefObject<HTMLMediaElement | null> | — | The <audio> or <video> to watch and drive. |
| returns.paused | boolean | — | As the element reports it: true before the first play and after a pause or the end. |
| returns.ended | boolean | — | True once playback reached the end, until the next play or seek. |
| returns.currentTime | number | — | Seconds, from timeupdate; a seek sets it at once. |
| returns.duration | number | — | Seconds; 0 until the metadata is in, and for a stream with no end. |
| returns.buffered | { start: number; end: number }[] | — | The ranges the browser holds, as fractions 0–1 of the duration, for a scrubber to draw. |
| returns.volume | number | — | 0–1. |
| returns.muted | boolean | — | Whether the element is muted, whatever its volume. |
| returns.playbackRate | number | — | 1 is normal speed. |
| returns.status | "loading" | "ready" | "error" | — | Whether a reader is waiting, nothing is on its way, or the file cannot be played. |
| returns.error | "missing" | "unsupported" | "unplayable" | "network" | "aborted" | — | What failed, while status is "error": missing and unsupported are told apart by the declared source types; unplayable is a code-4 failure with none declared. |
| returns.play() | () => Promise<void> | — | Starts the element; resolves once it has started or refused to, and never rejects. |
| returns.pause() | () => void | — | Pauses the element. |
| returns.toggle() | () => void | — | Plays a paused or finished element, pauses a playing one. |
| returns.seek() | (seconds: number) => void | — | Moves to a time, clamped to 0 and the duration. |
| returns.setVolume() | (volume: number) => void | — | Sets the volume, clamped to 0–1; the mute is left as it is. |
| returns.setMuted() | (muted: boolean) => void | — | Mutes or unmutes. |
| returns.setPlaybackRate() | (rate: number) => void | — | Sets the speed; 1 is normal. |
| formatMediaTime() | (seconds: number) => string | — | "0:05", "1:02", "1:02:03"; NaN, ±Infinity and negatives read "0:00". Re-exported from media-seek. |
| SPEEDS | readonly number[] | [0.5, 0.75, 1, 1.25, 1.5, 2] | media-seek: the speeds both players offer. |
| seekKeyTarget() | (event, currentTime: number, duration: number) => number | undefined | — | media-seek: where a key moves a seek bar, or undefined for a key it does not take — and for any key held with Ctrl, Meta or Alt. |
| fractionAt() | (element: Element, clientX: number) => number | — | media-seek: how far along a bar a pointer is, 0–1, in reading order. |
| seekValue() | (currentTime: number, duration: number) => { role; aria-valuemin; aria-valuemax; aria-valuenow; aria-valuetext } | — | media-seek: a seek bar's role and its words — "0:05 of 4:10", or the time alone while the length is unknown. |
| useSeekPointer() | ({ duration, onSeek, disabled? }) => { dragging, handlers } | — | media-seek: a press seeks where it lands and holds the pointer while it drags; spread handlers onto the bar. |
| isRightToLeft() | (element: Element) => boolean | — | media-seek: whether an element sits in a right-to-left run — the nearest dir, else its computed direction. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
// The times every player prints, shared with the seek bars; re-exported so a
// control built on the hook reads them from one import.
export { formatMediaTime } from "@/lib/media-seek"
/** What a media element is doing, as the kit's players draw it. */
export type MediaState = {
paused: boolean
ended: boolean
/** Seconds. */
currentTime: number
/** Seconds; 0 until the element knows, and for a stream with no end. */
duration: number
/** The ranges the browser holds, as fractions 0–1 of the duration. */
buffered: { start: number; end: number }[]
volume: number
muted: boolean
playbackRate: number
/**
* "loading" while the reader would be waiting — the first data, or more of
* it mid-play; "ready" when nothing is on its way, playing or not; "error"
* when the file cannot be played, with `error` saying why.
*/
status: "loading" | "ready" | "error"
/**
* What failed: "missing" — the browser could play the declared type, so
* the file is not there; "unsupported" — it can play none of the declared
* types, or could not decode the file; "unplayable" — no type was declared,
* and a missing file and an unplayable one look alike; "network" — the
* connection dropped; "aborted" — the load was stopped.
*/
error?: "missing" | "unsupported" | "unplayable" | "network" | "aborted"
}
export type MediaControls = {
/** Resolves once the element has started, or has refused to (an autoplay rule): never rejects. */
play(): Promise<void>
pause(): void
/** Plays a paused or finished element, pauses a playing one. */
toggle(): void
/** Seconds, clamped to the duration. */
seek(seconds: number): void
/** 0–1, clamped. */
setVolume(volume: number): void
setMuted(muted: boolean): void
setPlaybackRate(rate: number): void
}
const INITIAL: MediaState = {
paused: true,
ended: false,
currentTime: 0,
duration: 0,
buffered: [],
volume: 1,
muted: false,
playbackRate: 1,
status: "loading",
}
// The network states, by number: jsdom and every browser agree on them.
const NETWORK_IDLE = 1
const NETWORK_NO_SOURCE = 3
const HAVE_FUTURE_DATA = 3
/**
* A MediaError, named for what failed. MEDIA_ERR_SRC_NOT_SUPPORTED (4) is
* what a 404 and a format the browser cannot decode both give — in Chromium
* both leave the element with no source at all — so the code cannot tell a
* missing file from one this browser cannot play, and the types the element
* declares decide instead: see `noSource`.
*/
function errorOf(element: HTMLMediaElement, code: number | undefined): NonNullable<MediaState["error"]> {
if (code === 1) return "aborted"
if (code === 2) return "network"
if (code === 3) return "unsupported"
return noSource(element)
}
/**
* Why an element was left with no source it could use. If the browser can
* play one of the `<source type>`s it declares, the format was not the
* trouble, so the file is missing; if it can play none, the format is
* unsupported. With no type declared — a bare src — the two look alike, and
* the player says the file is not there or cannot be played here.
*/
function noSource(element: HTMLMediaElement): "missing" | "unsupported" | "unplayable" {
const types = Array.from(element.querySelectorAll("source[type]"), (source) => source.getAttribute("type") ?? "").filter(Boolean)
if (types.length === 0) return "unplayable"
return types.some((type) => element.canPlayType(type) !== "") ? "missing" : "unsupported"
}
/** Whether the element has a file to look for: a src, or a `<source>` with one. */
function hasSource(element: HTMLMediaElement): boolean {
return Boolean(element.getAttribute("src")) || element.querySelector("source[src]") !== null
}
const unit = (value: number) => Math.min(1, Math.max(0, value))
function readElement(element: HTMLMediaElement, status: MediaState["status"], error: MediaState["error"]): MediaState {
const duration = Number.isFinite(element.duration) && element.duration > 0 ? element.duration : 0
const buffered: MediaState["buffered"] = []
const ranges = element.buffered
if (duration > 0 && ranges) {
for (let index = 0; index < ranges.length; index++) {
buffered.push({ start: unit(ranges.start(index) / duration), end: unit(ranges.end(index) / duration) })
}
}
return {
paused: element.paused,
ended: element.ended,
currentTime: element.currentTime,
duration,
buffered,
volume: element.volume,
muted: element.muted,
playbackRate: element.playbackRate,
status,
error,
}
}
function sameState(a: MediaState, b: MediaState): boolean {
return (
a.paused === b.paused &&
a.ended === b.ended &&
a.currentTime === b.currentTime &&
a.duration === b.duration &&
a.volume === b.volume &&
a.muted === b.muted &&
a.playbackRate === b.playbackRate &&
a.status === b.status &&
a.error === b.error &&
a.buffered.length === b.buffered.length &&
a.buffered.every((range, index) => range.start === b.buffered[index].start && range.end === b.buffered[index].end)
)
}
/** Everything a media element says about itself; each one re-reads the element. */
const EVENTS = [
"loadstart",
"emptied",
"loadedmetadata",
"loadeddata",
"durationchange",
"canplay",
"canplaythrough",
"playing",
"waiting",
"suspend",
"play",
"pause",
"ended",
"timeupdate",
"seeking",
"seeked",
"progress",
"volumechange",
"ratechange",
] as const
/** Watches one element until the returned function is called. */
function watch(element: HTMLMediaElement, publish: (state: MediaState) => void): () => void {
let error: MediaState["error"] = element.error ? errorOf(element, element.error.code) : undefined
// Read once at the start, so a page that hydrates after its file has loaded
// — or after the browser chose not to fetch it (preload="none", a suspend)
// — is not left saying "loading" for an event that has already gone by.
let status: MediaState["status"] = error
? "error"
: element.readyState >= HAVE_FUTURE_DATA || element.networkState === NETWORK_IDLE
? "ready"
: "loading"
const update = () => publish(readElement(element, status, error))
const onEvent = (event: Event) => {
switch (event.type) {
case "loadstart":
case "emptied":
status = "loading"
error = undefined
break
case "canplay":
case "canplaythrough":
case "playing":
if (!error) status = "ready"
break
case "waiting":
if (!error) status = "loading"
break
case "suspend":
// The browser has stopped fetching on purpose: nothing is on its way
// until the reader asks for it, so there is nothing to wait for.
if (!error && element.paused) status = "ready"
break
}
update()
}
// In the capture phase, so it also hears a failing `<source>` child: with a
// list of sources the element itself fires nothing and keeps `error` null —
// each source fails on its own, and the last leaves no source at all.
const onError = (event: Event) => {
if (event.target === element) error = errorOf(element, element.error?.code)
else if (element.networkState === NETWORK_NO_SOURCE) error = noSource(element)
else return
status = "error"
update()
}
for (const type of EVENTS) element.addEventListener(type, onEvent)
element.addEventListener("error", onError, true)
// Sources that had all failed before anyone was listening leave no error to
// read, only an element with no source. Checked a turn later: a load that
// has only just begun says the same for the length of one task.
const settle = setTimeout(() => {
if (!error && element.networkState === NETWORK_NO_SOURCE && hasSource(element)) {
error = noSource(element)
status = "error"
update()
}
}, 0)
update()
return () => {
clearTimeout(settle)
for (const type of EVENTS) element.removeEventListener(type, onEvent)
element.removeEventListener("error", onError, true)
}
}
/**
* One media element's state, kept in step with its events, and the controls
* that drive it. The element is the truth: a control asks it, and the state
* changes when the element says it did — except a seek, which moves the
* reading at once so a scrubber does not jump back while the browser seeks.
*
* The ref may be filled late or swapped for another element; the hook follows
* whichever element it holds after each render.
*/
export function useMediaElement(ref: React.RefObject<HTMLMediaElement | null>): MediaState & MediaControls {
const [state, setState] = React.useState<MediaState>(INITIAL)
const watching = React.useRef<{ element: HTMLMediaElement; stop: () => void } | null>(null)
const publish = React.useCallback((next: MediaState) => {
setState((previous) => (sameState(previous, next) ? previous : next))
}, [])
// After every render: the ref is read, not depended on, because filling a
// ref does not render — an element that mounts later, or a remounted one,
// is picked up on the render that put it there.
React.useEffect(() => {
const element = ref.current
if (watching.current?.element === element) return
watching.current?.stop()
watching.current = element ? { element, stop: watch(element, publish) } : null
})
React.useEffect(
() => () => {
watching.current?.stop()
watching.current = null
},
[]
)
const play = React.useCallback(async () => {
try {
await ref.current?.play()
} catch {
// Refused — no gesture yet — or cut short by a pause. The element stays
// paused and its own events say so; a player has nothing to add.
}
}, [ref])
const pause = React.useCallback(() => ref.current?.pause(), [ref])
const toggle = React.useCallback(() => {
const element = ref.current
if (!element) return
if (element.paused || element.ended) void play()
else element.pause()
}, [ref, play])
const seek = React.useCallback(
(seconds: number) => {
const element = ref.current
if (!element || Number.isNaN(seconds)) return
const end = Number.isFinite(element.duration) && element.duration > 0 ? element.duration : Number.POSITIVE_INFINITY
const time = Math.min(Math.max(0, seconds), end)
if (!Number.isFinite(time)) return
element.currentTime = time
setState((previous) => ({ ...previous, currentTime: time, ended: false }))
},
[ref]
)
const setVolume = React.useCallback(
(volume: number) => {
if (ref.current && !Number.isNaN(volume)) ref.current.volume = Math.min(1, Math.max(0, volume))
},
[ref]
)
const setMuted = React.useCallback(
(muted: boolean) => {
if (ref.current) ref.current.muted = muted
},
[ref]
)
const setPlaybackRate = React.useCallback(
(rate: number) => {
if (ref.current && rate > 0) ref.current.playbackRate = rate
},
[ref]
)
return React.useMemo(
() => ({ ...state, play, pause, toggle, seek, setVolume, setMuted, setPlaybackRate }),
[state, play, pause, toggle, seek, setVolume, setMuted, setPlaybackRate]
)
}import * as React from "react"
/**
* What the kit's two players share about seeking: the times a seek bar
* prints, the keys that move it, where a pointer lands on it, and the
* speeds both offer. Shipped beside `useMediaElement` by its item, so the
* video player's scrubber and the audio player's waveform read one table
* and cannot drift apart. A seek bar runs in reading order: in a
* right-to-left page it fills from the right, and ArrowLeft goes forward.
*/
/** The speeds both players offer. */
export const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
/** Seconds an arrow key moves a seek bar. */
export const SEEK_STEP = 5
/** `5` → "0:05", `62` → "1:02", `3723` → "1:02:03"; anything that is not a time yet reads "0:00". */
export function formatMediaTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00"
const whole = Math.floor(seconds)
const hours = Math.floor(whole / 3600)
const minutes = Math.floor((whole % 3600) / 60)
const rest = String(whole % 60).padStart(2, "0")
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`
}
/** Whether an element sits in a right-to-left run: the nearest dir attribute, else its computed direction. */
export function isRightToLeft(element: Element): boolean {
const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (attribute === "rtl" || attribute === "ltr") return attribute === "rtl"
return getComputedStyle(element).direction === "rtl"
}
const unit = (value: number) => Math.min(1, Math.max(0, value))
/** The role and the aria-value attributes of a seek bar: the time out of the whole, in whole seconds. */
export function seekValue(currentTime: number, duration: number) {
return {
role: "slider",
"aria-valuemin": 0,
"aria-valuemax": Math.round(duration),
"aria-valuenow": Math.round(currentTime),
// Until the file says how long it is, only the time played.
"aria-valuetext": duration > 0 ? `${formatMediaTime(currentTime)} of ${formatMediaTime(duration)}` : formatMediaTime(currentTime),
} as const
}
type SeekKey = { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; currentTarget: Element }
/**
* Where a key moves a seek bar, in seconds, or undefined for a key it does
* not take: five seconds an arrow (ArrowLeft and ArrowRight in reading
* order), a tenth of the whole a page (at least an arrow's step), Home and
* End to the ends. A key pressed with Ctrl, Meta or Alt belongs to the
* browser and the page, never to the bar.
*/
export function seekKeyTarget(event: SeekKey, currentTime: number, duration: number): number | undefined {
if (event.altKey || event.ctrlKey || event.metaKey) return undefined
const rtl = isRightToLeft(event.currentTarget)
const page = Math.max(SEEK_STEP, duration / 10)
switch (event.key) {
case "ArrowRight":
return currentTime + (rtl ? -SEEK_STEP : SEEK_STEP)
case "ArrowLeft":
return currentTime + (rtl ? SEEK_STEP : -SEEK_STEP)
case "ArrowUp":
return currentTime + SEEK_STEP
case "ArrowDown":
return currentTime - SEEK_STEP
case "PageUp":
return currentTime + page
case "PageDown":
return currentTime - page
case "Home":
return 0
case "End":
return duration
default:
return undefined
}
}
/** How far along a bar a pointer is, 0–1, in reading order; nothing on a bar with no width. */
export function fractionAt(element: Element, clientX: number): number {
const box = element.getBoundingClientRect()
if (box.width <= 0) return 0
const fraction = unit((clientX - box.left) / box.width)
return isRightToLeft(element) ? 1 - fraction : fraction
}
/**
* The pointer half of a seek bar: a press seeks where it lands and holds
* the pointer, so a drag keeps seeking even when it leaves the bar, until
* the pointer comes up. Spread `handlers` onto the bar.
*/
export function useSeekPointer({ duration, onSeek, disabled = false }: { duration: number; onSeek: (seconds: number) => void; disabled?: boolean }) {
const [dragging, setDragging] = React.useState(false)
const handlers = {
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return
event.currentTarget.setPointerCapture?.(event.pointerId)
setDragging(true)
onSeek(fractionAt(event.currentTarget, event.clientX) * duration)
},
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
if (dragging) onSeek(fractionAt(event.currentTarget, event.clientX) * duration)
},
onPointerUp: (event: React.PointerEvent<HTMLElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId)
setDragging(false)
},
onPointerCancel: () => setDragging(false),
}
return { dragging, handlers }
}