Media
Video player
Custom controls for a native video: seek with time preview, speed, captions, picture in picture, full screen.
- Built on
<video><track>TextTrack APIFullscreen APIPicture-in-Picture APIContainer queries- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 7.3 kB
- Built from
- Icon
Code
"use client";
import { useEffect, useState } from "react";
import { VideoPlayer } from "@/components/equip/video-player";
/* A six second sunrise, drawn on a canvas and recorded in the browser, so the demo fetches nothing. */
function sunrise(onReady: (clip: { poster: string; src?: string }) => void) {
const canvas = Object.assign(document.createElement("canvas"), { width: 320, height: 180 });
const g = canvas.getContext("2d");
if (!g) return () => {};
const draw = (t: number) => {
const sky = g.createLinearGradient(0, 0, 0, 180);
sky.addColorStop(0, `hsl(${215 - t * 12} 55% ${30 + t * 7}%)`);
sky.addColorStop(1, `hsl(${30 + t * 3} 80% ${60 + t * 3}%)`);
g.fillStyle = sky;
g.fillRect(0, 0, 320, 180);
g.fillStyle = "hsl(42 95% 62%)";
g.beginPath();
g.arc(210, 150 - t * 16, 22, 0, Math.PI * 2);
g.fill();
g.fillStyle = "hsl(160 25% 18%)";
g.fillRect(0, 128, 320, 52);
};
draw(0);
const poster = canvas.toDataURL();
onReady({ poster });
if (typeof MediaRecorder === "undefined" || !canvas.captureStream) return () => {};
const recorder = new MediaRecorder(canvas.captureStream(24));
const chunks: Blob[] = [];
let frame = 0;
const timer = window.setInterval(() => draw(Math.min(6, ++frame / 24)), 1000 / 24);
recorder.ondataavailable = (event) => chunks.push(event.data);
recorder.onstop = () => onReady({ poster, src: URL.createObjectURL(new Blob(chunks, { type: recorder.mimeType })) });
recorder.start();
const stop = window.setTimeout(() => recorder.stop(), 6000);
return () => {
window.clearInterval(timer);
window.clearTimeout(stop);
// Stopped early, on unmount: drop the half-made clip rather than publish it.
recorder.onstop = null;
if (recorder.state !== "inactive") recorder.stop();
};
}
export default function Demo() {
const [clip, setClip] = useState<{ poster?: string; src?: string }>({});
useEffect(() => sunrise(setClip), []);
useEffect(() => () => void (clip.src && URL.revokeObjectURL(clip.src)), [clip.src]);
return <VideoPlayer label="Sunrise over the bay" src={clip.src} poster={clip.poster} style={{ width: 216 }} />;
}Examples
Captions from a track
A track of kind captions or subtitles adds the captions button; default turns them on. Press C to toggle them.
Code
"use client";
import { useEffect, useState } from "react";
import { VideoPlayer } from "@/components/equip/video-player";
/* An eight second title card, drawn on a canvas and recorded in the browser. */
function titleCard(onReady: (clip: { poster: string; src?: string }) => void) {
const canvas = Object.assign(document.createElement("canvas"), { width: 480, height: 270 });
const g = canvas.getContext("2d");
if (!g) return () => {};
const draw = (t: number) => {
g.fillStyle = "hsl(220 30% 14%)";
g.fillRect(0, 0, 480, 270);
g.fillStyle = "hsl(18 90% 58%)";
g.fillRect(40, 70, Math.min(400, t * 80), 10);
g.fillStyle = "hsl(40 30% 92%)";
g.font = "600 30px system-ui, sans-serif";
g.fillText("Quarterly review", 40, 130);
g.font = "18px system-ui, sans-serif";
g.fillStyle = "hsl(40 15% 70%)";
g.fillText(`Revenue up ${Math.round(Math.min(t, 6) * 3)}%`, 40, 165);
};
draw(0);
const poster = canvas.toDataURL();
onReady({ poster });
if (typeof MediaRecorder === "undefined" || !canvas.captureStream) return () => {};
const recorder = new MediaRecorder(canvas.captureStream(24));
const chunks: Blob[] = [];
let frame = 0;
const timer = window.setInterval(() => draw(++frame / 24), 1000 / 24);
recorder.ondataavailable = (event) => chunks.push(event.data);
recorder.onstop = () => onReady({ poster, src: URL.createObjectURL(new Blob(chunks, { type: recorder.mimeType })) });
recorder.start();
const stop = window.setTimeout(() => recorder.stop(), 8000);
return () => {
window.clearInterval(timer);
window.clearTimeout(stop);
// Stopped early, on unmount: drop the half-made clip rather than publish it.
recorder.onstop = null;
if (recorder.state !== "inactive") recorder.stop();
};
}
const vtt = `WEBVTT
00:00.300 --> 00:02.800
Welcome to the quarterly review.
00:03.000 --> 00:05.500
Revenue grew in every region.
00:05.700 --> 00:08.000
Here is where it came from.
`;
export default function Example() {
const [clip, setClip] = useState<{ poster?: string; src?: string }>({});
const [track, setTrack] = useState<string>();
useEffect(() => titleCard(setClip), []);
useEffect(() => () => void (clip.src && URL.revokeObjectURL(clip.src)), [clip.src]);
useEffect(() => {
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
setTrack(url);
return () => URL.revokeObjectURL(url);
}, []);
return (
<VideoPlayer label="Quarterly review" src={clip.src} poster={clip.poster} style={{ maxWidth: 520 }}>
{track && <track kind="captions" src={track} srcLang="en" label="English" default />}
</VideoPlayer>
);
}Portrait clip, no picture in picture
ratio shapes the frame and the clip is letterboxed inside it; native attributes such as disablePictureInPicture and loop pass straight through.
Code
"use client";
import { useEffect, useState } from "react";
import { VideoPlayer } from "@/components/equip/video-player";
/* A four second vertical clip of a ball bouncing, recorded from a canvas in the browser. */
function bounce(onReady: (clip: { poster: string; src?: string }) => void) {
const canvas = Object.assign(document.createElement("canvas"), { width: 180, height: 320 });
const g = canvas.getContext("2d");
if (!g) return () => {};
const draw = (t: number) => {
g.fillStyle = "hsl(265 35% 22%)";
g.fillRect(0, 0, 180, 320);
const y = 250 - Math.abs(Math.sin(t * Math.PI)) * 180;
g.fillStyle = "hsl(330 80% 66%)";
g.beginPath();
g.arc(90, y, 22, 0, Math.PI * 2);
g.fill();
g.fillStyle = "hsl(265 25% 40%)";
g.fillRect(0, 272, 180, 48);
};
draw(0);
const poster = canvas.toDataURL();
onReady({ poster });
if (typeof MediaRecorder === "undefined" || !canvas.captureStream) return () => {};
const recorder = new MediaRecorder(canvas.captureStream(24));
const chunks: Blob[] = [];
let frame = 0;
const timer = window.setInterval(() => draw(++frame / 24), 1000 / 24);
recorder.ondataavailable = (event) => chunks.push(event.data);
recorder.onstop = () => onReady({ poster, src: URL.createObjectURL(new Blob(chunks, { type: recorder.mimeType })) });
recorder.start();
const stop = window.setTimeout(() => recorder.stop(), 4000);
return () => {
window.clearInterval(timer);
window.clearTimeout(stop);
// Stopped early, on unmount: drop the half-made clip rather than publish it.
recorder.onstop = null;
if (recorder.state !== "inactive") recorder.stop();
};
}
export default function Example() {
const [clip, setClip] = useState<{ poster?: string; src?: string }>({});
useEffect(() => bounce(setClip), []);
useEffect(() => () => void (clip.src && URL.revokeObjectURL(clip.src)), [clip.src]);
return (
<VideoPlayer
label="Bounce loop"
ratio="9 / 16"
src={clip.src}
poster={clip.poster}
loop
muted
disablePictureInPicture
style={{ width: 220 }}
/>
);
}Waiting for a source
Without a source the position is disabled and the length reads --:--; set src or add source children when the file is ready.
Code
import { VideoPlayer } from "@/components/equip/video-player";
export default function Example() {
return <VideoPlayer label="Onboarding walkthrough" preload="none" style={{ maxWidth: 520 }} />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | "Video player" | Names the player group for assistive tech. |
rates | number[] | [0.5, 1, 1.25, 1.5, 2] | Speeds the speed button steps through. One rate or none hides it. |
ratio | string | "16 / 9" | Frame shape as a CSS aspect-ratio. The video is letterboxed inside it. |
hideAfter | number | 2500 | Milliseconds without pointer movement before the controls fade while playing. |
children | ReactNode | none | source and track elements, passed into the video. |
...rest | ComponentProps<"video"> | none | Every native video attribute and handler, including src, poster, muted, loop, disablePictureInPicture and ref. className and style go to the frame. |
Anatomy
| root | .eq-video-player | The frame, a labelled group; full screen is requested on it so the controls come along. |
|---|---|---|
| video | .eq-video-player-video | The native video. A click plays or pauses, a double click goes full screen. |
| big | .eq-video-player-big | The large play button over the poster, until the first play. |
| captions | .eq-video-player-captions | Where cues are drawn, above the bar. |
| cue | .eq-video-player-cue | |
| error | .eq-video-player-error | Shown when the source fails. |
| bar | .eq-video-player-bar | The floating control bar; data-idle on the root fades it. |
| scrub | .eq-video-player-scrub | Holds the seek bar and reads the mouse for the preview. |
| seek | .eq-video-player-seek | A native range painted with played, buffered and the rest. |
| preview | .eq-video-player-preview | The time under the mouse. |
| controls | .eq-video-player-controls | |
| button | .eq-video-player-button | Play, mute, speed, captions, picture in picture and full screen; data-control names the ones styled apart. |
| volume | .eq-video-player-volume | A native range, hidden below 420px wide. |
| time | .eq-video-player-time | |
| spacer | .eq-video-player-spacer |
Keyboard
| SpaceK | Plays or pauses, from anywhere in the player except a button, where Space presses it. |
|---|---|
| ArrowLeftArrowRight | Seeks back or forward 5 seconds. On the volume slider they change the volume. |
| JL | Seeks back or forward 10 seconds. |
| ArrowUpArrowDown | Raises or lowers the volume by 10%. |
| M | Mutes or unmutes. |
| F | Enters or leaves full screen, where supported. |
| C | Turns captions on or off, when the video has a captions or subtitles track. |
| HomeEnd | Jumps to the start or the end. |
| Tab | Moves through the controls in order and out of the player; focus is never trapped, and focusing a control shows the bar. |
Notes
- The native video does the playing; every attribute, handler and source or track child passes through to it.
- The controls fade with opacity only while the video plays and the pointer rests, so they stay in the tab order, and keyboard focus inside brings them back.
- On frames under 300px wide the bar appears after the first play; until then the large play button is the only control, and the shortcut keys still work.
- Captions come from track children of kind captions or subtitles; the part runs the track hidden and draws its cues above the bar so they never sit under the controls.
- Picture in picture and full screen buttons appear only where the browser offers them, checked after mount.
- It is a size container, so it takes the full width of its box rather than sizing to its content; set a width or max-width to limit it.
- Shortcut keys stop at the player while focus is inside it, so they never reach page shortcuts such as F or C.
- Clicking the frame focuses it without a ring, so the shortcuts work after a click too.
- The demo and examples draw their clips on a canvas and record them with MediaRecorder in the browser, so they fetch nothing; the clips have no sound.
- Control names are English; copy the part and change them to translate.
- Picture in picture is missing in Firefox's API and full screen of an element is missing on iPhone; there those buttons are hidden, and on iPhone F and double click use the native video full screen.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/video-player.tsx528 lines
"use client";
import {
useEffect,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type Ref,
} from "react";
import { Icon } from "./icon";
import "./video-player.css";
export type VideoPlayerProps = Omit<ComponentProps<"video">, "controls"> & {
/** Names the player for assistive tech, e.g. "Product tour". */
label?: string;
/** Speeds the speed button steps through. */
rates?: number[];
/** Frame shape as a CSS aspect-ratio. The video is letterboxed inside it. */
ratio?: string;
/** Milliseconds without pointer movement before the controls fade while playing. */
hideAfter?: number;
};
type State = {
playing: boolean;
started: boolean;
time: number;
duration: number;
buffered: number;
volume: number;
muted: boolean;
rate: number;
waiting: boolean;
failed: boolean;
};
const START: State = {
playing: false,
started: false,
time: 0,
duration: Number.NaN,
buffered: 0,
volume: 1,
muted: false,
rate: 1,
waiting: false,
failed: false,
};
const EVENTS = [
"play",
"pause",
"playing",
"waiting",
"canplay",
"timeupdate",
"durationchange",
"loadedmetadata",
"progress",
"volumechange",
"ratechange",
"seeked",
"ended",
"emptied",
"error",
] as const;
/** 65 gives "1:05", 3725 gives "1:02:05", an unknown length gives "--:--". */
function clock(seconds: number): string {
if (!Number.isFinite(seconds)) return "--:--";
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
function spoken(seconds: number): string {
if (!Number.isFinite(seconds)) return "unknown";
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return [m > 0 ? `${m} minute${m === 1 ? "" : "s"}` : "", `${s} second${s === 1 ? "" : "s"}`].filter(Boolean).join(" ");
}
function bufferedEnd(media: HTMLMediaElement): number {
const ranges = media.buffered;
for (let i = 0; i < ranges.length; i++) {
if (ranges.start(i) <= media.currentTime && media.currentTime <= ranges.end(i)) return ranges.end(i);
}
return ranges.length > 0 ? ranges.end(ranges.length - 1) : 0;
}
function assign<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) ref.current = value;
}
function captionTracks(video: HTMLVideoElement): TextTrack[] {
return Array.from(video.textTracks).filter((t) => t.kind === "captions" || t.kind === "subtitles");
}
function Glyph({ d }: { d: string }) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
/* A frame with two lines of text, and a frame with a small window in its corner. */
const CAPTIONS = "M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM7 12.5h3M12.5 12.5h4.5M7 15.5h6.5";
const PIP = "M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM12.5 12h5v4h-5z";
export function VideoPlayer({
label = "Video player",
rates = [0.5, 1, 1.25, 1.5, 2],
ratio = "16 / 9",
hideAfter = 2500,
className,
style,
ref,
children,
...rest
}: VideoPlayerProps) {
const root = useRef<HTMLDivElement>(null);
const video = useRef<HTMLVideoElement | null>(null);
const playButton = useRef<HTMLButtonElement>(null);
const refocus = useRef(false);
const idleTimer = useRef<number | undefined>(undefined);
const [state, setState] = useState<State>(START);
const [idle, setIdle] = useState(false);
const [hover, setHover] = useState<number | null>(null);
const [captions, setCaptions] = useState({ available: false, on: false, text: "" });
const [can, setCan] = useState({ fullscreen: false, pip: false });
const [fullscreen, setFullscreen] = useState(false);
const [pip, setPip] = useState(false);
// Mirror the video element's state.
useEffect(() => {
const media = video.current;
if (!media) return;
const sync = () =>
setState({
playing: !media.paused,
started: !media.paused || media.currentTime > 0,
time: media.currentTime,
duration: media.duration,
buffered: bufferedEnd(media),
volume: media.volume,
muted: media.muted,
rate: media.playbackRate,
waiting: !media.paused && media.readyState < 3,
failed: media.error !== null,
});
for (const name of EVENTS) media.addEventListener(name, sync);
sync();
return () => {
for (const name of EVENTS) media.removeEventListener(name, sync);
};
}, []);
// What this engine can do, and caption tracks as they arrive.
useEffect(() => {
const media = video.current;
const frame = root.current;
if (!media || !frame) return;
setCan({
fullscreen: Boolean(document.fullscreenEnabled) || "webkitEnterFullscreen" in media,
pip: Boolean(document.pictureInPictureEnabled) && !media.disablePictureInPicture,
});
// The part draws cues itself, above the controls, so tracks run hidden rather than showing.
let active: TextTrack | null = null;
const onCue = () => {
const cues = active?.activeCues;
const text = cues
? Array.from(cues)
.map((cue) => ("text" in cue && typeof cue.text === "string" ? cue.text.replace(/<[^>]+>/g, "") : ""))
.join("\n")
: "";
setCaptions((c) => ({ ...c, text }));
};
const scan = () => {
const tracks = captionTracks(media);
const showing = tracks.find((t) => t.mode === "showing" || t.mode === "hidden") ?? null;
if (showing !== active) {
active?.removeEventListener("cuechange", onCue);
active = showing;
active?.addEventListener("cuechange", onCue);
}
for (const t of tracks) if (t.mode === "showing") t.mode = "hidden";
setCaptions({ available: tracks.length > 0, on: active !== null, text: "" });
onCue();
};
const list = media.textTracks;
list.addEventListener("addtrack", scan);
list.addEventListener("removetrack", scan);
list.addEventListener("change", scan);
scan();
const onFull = () => setFullscreen(document.fullscreenElement === frame);
const onPipIn = () => setPip(true);
const onPipOut = () => setPip(false);
frame.addEventListener("fullscreenchange", onFull);
media.addEventListener("enterpictureinpicture", onPipIn);
media.addEventListener("leavepictureinpicture", onPipOut);
return () => {
list.removeEventListener("addtrack", scan);
list.removeEventListener("removetrack", scan);
list.removeEventListener("change", scan);
active?.removeEventListener("cuechange", onCue);
frame.removeEventListener("fullscreenchange", onFull);
media.removeEventListener("enterpictureinpicture", onPipIn);
media.removeEventListener("leavepictureinpicture", onPipOut);
};
}, []);
useEffect(() => () => window.clearTimeout(idleTimer.current), []);
// Playback started any way at all (autoplay, a media key, a ref) arms the fade too.
useEffect(() => {
if (!state.playing) return;
setIdle(false);
window.clearTimeout(idleTimer.current);
idleTimer.current = window.setTimeout(() => setIdle(true), hideAfter);
}, [state.playing, hideAfter]);
// The big play button leaves once playback starts; focus it held moves to the bar's play button.
useEffect(() => {
if (!state.started || !refocus.current) return;
refocus.current = false;
playButton.current?.focus({ preventScroll: true });
}, [state.started]);
// Controls fade only while playing, after the pointer rests.
function wake() {
setIdle(false);
window.clearTimeout(idleTimer.current);
idleTimer.current = window.setTimeout(() => setIdle(true), hideAfter);
}
const known = Number.isFinite(state.duration) && state.duration > 0;
function toggle() {
const media = video.current;
if (!media) return;
if (media.paused) void media.play().catch(() => {});
else media.pause();
wake();
}
function seekTo(seconds: number) {
const media = video.current;
if (!media || !known) return;
media.currentTime = Math.min(Math.max(seconds, 0), state.duration);
}
function setVolume(next: number) {
const media = video.current;
if (!media) return;
media.volume = Math.min(1, Math.max(0, Math.round(next * 100) / 100));
if (media.volume > 0) media.muted = false;
}
function toggleMute() {
const media = video.current;
if (!media) return;
if (media.muted || media.volume === 0) {
media.muted = false;
if (media.volume === 0) media.volume = 0.5;
} else media.muted = true;
}
function cycleRate() {
const media = video.current;
if (!media || rates.length === 0) return;
const at = rates.findIndex((r) => r === media.playbackRate);
media.playbackRate = rates[(at + 1) % rates.length] ?? 1;
}
function toggleCaptions() {
const media = video.current;
if (!media) return;
const tracks = captionTracks(media);
if (tracks.length === 0) return;
const on = tracks.some((t) => t.mode !== "disabled");
for (const t of tracks) t.mode = "disabled";
const first = tracks[0];
if (!on && first) first.mode = "hidden";
}
function toggleFullscreen() {
const media = video.current;
const frame = root.current;
if (!media || !frame) return;
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else if (frame.requestFullscreen) void frame.requestFullscreen().catch(() => {});
else if ("webkitEnterFullscreen" in media && typeof media.webkitEnterFullscreen === "function") media.webkitEnterFullscreen();
}
function togglePip() {
const media = video.current;
if (!media) return;
if (document.pictureInPictureElement === media) void document.exitPictureInPicture().catch(() => {});
else void media.requestPictureInPicture().catch(() => {});
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
const target = event.target;
const onButton = target instanceof HTMLButtonElement;
const range = target instanceof HTMLInputElement && target.type === "range" ? target : null;
const onVolume = range?.dataset.control === "volume";
const key = event.key;
if ((key === " " || key === "Enter") && onButton) return;
if (onVolume && key.startsWith("Arrow")) return;
if (range && (key === "Home" || key === "End" || key === "PageUp" || key === "PageDown")) return;
const media = video.current;
if (!media) return;
const lower = key.length === 1 ? key.toLowerCase() : key;
if (lower === " " || lower === "k") toggle();
else if (lower === "ArrowLeft") seekTo(media.currentTime - 5);
else if (lower === "ArrowRight") seekTo(media.currentTime + 5);
else if (lower === "j") seekTo(media.currentTime - 10);
else if (lower === "l") seekTo(media.currentTime + 10);
else if (lower === "ArrowUp") setVolume((media.muted ? 0 : media.volume) + 0.1);
else if (lower === "ArrowDown") setVolume(media.volume - 0.1);
else if (lower === "m") toggleMute();
else if (lower === "f" && can.fullscreen) toggleFullscreen();
else if (lower === "c" && captions.available) toggleCaptions();
else if (lower === "Home") seekTo(0);
else if (lower === "End") seekTo(state.duration);
else return;
event.preventDefault();
event.stopPropagation();
wake();
}
function scrubFraction(event: PointerEvent<HTMLElement>) {
const box = event.currentTarget.getBoundingClientRect();
return box.width > 0 ? Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)) : 0;
}
const played = known ? Math.min(1, state.time / state.duration) : 0;
const loaded = known ? Math.min(1, state.buffered / state.duration) : 0;
const silent = state.muted || state.volume === 0;
return (
<div
ref={root}
role="group"
aria-label={label}
tabIndex={-1}
className={["eq-video-player", className].filter(Boolean).join(" ")}
style={{ ...style, "--_ratio": ratio } as CSSProperties}
data-playing={state.playing || undefined}
data-started={state.started || undefined}
data-idle={(state.playing && idle) || undefined}
data-fullscreen={fullscreen || undefined}
onKeyDown={handleKeyDown}
onPointerMove={wake}
onPointerDown={wake}
onPointerLeave={() => state.playing && setIdle(true)}
>
<video
playsInline
preload="metadata"
{...rest}
ref={(node) => {
video.current = node;
assign(ref, node);
}}
className="eq-video-player-video"
onClick={(event) => {
rest.onClick?.(event);
if (!event.defaultPrevented) toggle();
}}
onDoubleClick={(event) => {
rest.onDoubleClick?.(event);
if (!event.defaultPrevented && can.fullscreen) toggleFullscreen();
}}
>
{children}
</video>
{captions.on && captions.text && (
<div className="eq-video-player-captions" aria-hidden="true">
<span className="eq-video-player-cue">{captions.text}</span>
</div>
)}
{!state.started && (
<button type="button" className="eq-video-player-big" aria-label={label === "Video player" ? "Play video" : `Play ${label}`}
onClick={(event) => {
refocus.current = document.activeElement === event.currentTarget;
toggle();
}}
>
<Icon name="play" size={24} />
</button>
)}
{state.failed && (
<p className="eq-video-player-error" role="status">
This video could not be played.
</p>
)}
<div className="eq-video-player-bar">
<div
className="eq-video-player-scrub"
onPointerMove={(event) => event.pointerType === "mouse" && known && setHover(scrubFraction(event))}
onPointerLeave={() => setHover(null)}
style={{ "--_hover": hover ?? 0 } as CSSProperties}
>
<input
className="eq-video-player-seek"
type="range"
min={0}
max={known ? state.duration : 1}
step="any"
value={known ? state.time : 0}
disabled={!known}
aria-label="Position"
aria-valuetext={known ? `${spoken(state.time)} of ${spoken(state.duration)}` : "Not loaded"}
style={{ "--_played": played, "--_loaded": loaded } as CSSProperties}
onChange={(event) => seekTo(event.currentTarget.valueAsNumber)}
/>
{hover !== null && (
<span className="eq-video-player-preview" aria-hidden="true">
{clock(hover * state.duration)}
</span>
)}
</div>
<div className="eq-video-player-controls">
<button
ref={playButton}
type="button"
className="eq-video-player-button"
aria-label={state.playing ? "Pause" : "Play"}
data-waiting={state.waiting || undefined}
onClick={toggle}
>
<Icon name={state.playing ? "pause" : "play"} size={18} />
</button>
<button
type="button"
className="eq-video-player-button"
data-control="mute"
aria-label="Mute"
aria-pressed={silent}
onClick={toggleMute}
>
<Icon name={silent ? "volume-off" : "volume"} size={18} />
</button>
<input
className="eq-video-player-volume"
type="range"
data-control="volume"
min={0}
max={1}
step={0.05}
value={silent ? 0 : state.volume}
aria-label="Volume"
aria-valuetext={`${Math.round((silent ? 0 : state.volume) * 100)}%`}
style={{ "--_played": silent ? 0 : state.volume } as CSSProperties}
onChange={(event) => setVolume(event.currentTarget.valueAsNumber)}
/>
<span className="eq-video-player-time" aria-hidden="true">
{clock(state.time)} / {clock(state.duration)}
</span>
<span className="eq-video-player-spacer" />
{rates.length > 1 && (
<button
type="button"
className="eq-video-player-button"
data-control="rate"
aria-label={`Playback speed ${state.rate}×`}
onClick={cycleRate}
>
{state.rate}×
</button>
)}
{captions.available && (
<button
type="button"
className="eq-video-player-button"
data-control="captions"
aria-label="Captions"
aria-pressed={captions.on}
onClick={toggleCaptions}
>
<Glyph d={CAPTIONS} />
</button>
)}
{can.pip && (
<button
type="button"
className="eq-video-player-button"
data-control="pip"
aria-label="Picture in picture"
aria-pressed={pip}
onClick={togglePip}
>
<Glyph d={PIP} />
</button>
)}
{can.fullscreen && (
<button
type="button"
className="eq-video-player-button"
data-control="fullscreen"
aria-label="Full screen"
aria-pressed={fullscreen}
onClick={toggleFullscreen}
>
<Icon name={fullscreen ? "minimize" : "maximize"} size={18} />
</button>
)}
</div>
</div>
</div>
);
}components/equip/video-player.css408 lines
@layer equip {
.eq-video-player {
position: relative;
box-sizing: border-box;
display: grid;
/* A size container has no width of its own, so it fills its box unless given one. */
width: 100%;
min-width: 0;
aspect-ratio: var(--_ratio, 16 / 9);
overflow: hidden;
border-radius: var(--eq-radius-l);
background: color-mix(in srgb, var(--eq-ink) 10%, var(--eq-surface));
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.35 var(--eq-font);
container-type: inline-size;
}
.eq-video-player:focus {
outline: none;
}
.eq-video-player[data-fullscreen] {
border-radius: 0;
aspect-ratio: auto;
}
.eq-video-player[data-idle] {
cursor: none;
}
.eq-video-player-video {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
min-height: 0;
object-fit: contain;
cursor: pointer;
}
.eq-video-player[data-idle] .eq-video-player-video {
cursor: none;
}
/* The control bar floats inside the frame and fades while the video plays and the pointer rests.
Opacity only, so it stays in the tab order, and keyboard focus inside brings it straight back. */
.eq-video-player-bar {
position: absolute;
right: var(--eq-space-2);
bottom: var(--eq-space-2);
left: var(--eq-space-2);
display: grid;
padding: 0 var(--eq-space-1) var(--eq-space-1);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
background: color-mix(in srgb, var(--eq-raised) 88%, transparent);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
.eq-video-player[data-idle] .eq-video-player-bar {
opacity: 0;
translate: 0 4px;
}
.eq-video-player[data-idle]:has(:focus-visible) .eq-video-player-bar {
opacity: 1;
translate: none;
}
.eq-video-player-scrub {
position: relative;
display: flex;
padding-inline: var(--eq-space-1);
}
.eq-video-player-preview {
position: absolute;
bottom: calc(100% - 2px);
left: calc(var(--eq-space-1) + (100% - 2 * var(--eq-space-1)) * var(--_hover));
translate: -50% 0;
padding: 3px 5px;
border-radius: var(--eq-radius-s);
background: var(--eq-solid);
color: var(--eq-solid-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.eq-video-player-controls {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.eq-video-player-spacer {
flex: 1;
}
.eq-video-player-time {
flex: none;
padding-inline: var(--eq-space-1);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-video-player-button {
position: relative;
box-sizing: border-box;
display: inline-grid;
flex: none;
place-items: center;
min-width: var(--eq-h-s);
height: var(--eq-h-s);
margin: 0;
padding: 0 var(--eq-space-1);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-video-player-button[data-control="rate"] {
min-width: 38px;
}
.eq-video-player-button[aria-pressed="true"]:is([data-control="captions"], [data-control="pip"]) {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-video-player-button[data-control="mute"][aria-pressed="true"] {
color: var(--eq-ink-2);
}
.eq-video-player-button[data-waiting]::after {
content: "";
position: absolute;
inset: 2px;
border: 2px solid transparent;
border-top-color: var(--eq-ink-2);
border-radius: var(--eq-radius-full);
animation: eq-video-player-spin 800ms linear infinite;
}
@keyframes eq-video-player-spin {
to {
rotate: 1turn;
}
}
@media (hover: hover) {
.eq-video-player-button:hover:not([aria-pressed="true"]:is([data-control="captions"], [data-control="pip"])) {
background: var(--eq-tint-strong);
}
.eq-video-player-big:hover {
background: color-mix(in srgb, var(--eq-raised) 100%, var(--eq-ink) 6%);
}
}
.eq-video-player-button:active,
.eq-video-player-big:active {
scale: var(--eq-press);
}
.eq-video-player-button:focus-visible,
.eq-video-player-big:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Buttons sit 2px apart, so the ring is lifted over its neighbour instead of being covered by it. */
.eq-video-player-button:focus-visible {
z-index: 1;
}
/* Before the first play: one large round button over the poster. */
.eq-video-player-big {
position: absolute;
top: calc(50% - 20px);
left: 50%;
display: grid;
place-items: center;
width: 52px;
height: 52px;
margin: 0;
padding: 0 0 0 3px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
translate: -50% -50%;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-video-player-captions {
position: absolute;
right: var(--eq-space-4);
bottom: calc(var(--eq-h-s) + 44px);
left: var(--eq-space-4);
display: flex;
justify-content: center;
text-align: center;
pointer-events: none;
transition: translate var(--eq-dur-move) var(--eq-spring);
}
.eq-video-player[data-idle] .eq-video-player-captions {
translate: 0 calc(var(--eq-h-s) + 28px);
}
.eq-video-player-cue {
padding: 2px var(--eq-space-2);
border-radius: var(--eq-radius-s);
background: color-mix(in srgb, var(--eq-solid) 82%, transparent);
color: var(--eq-solid-ink);
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
line-height: 1.4;
white-space: pre-line;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.eq-video-player-error {
position: absolute;
top: var(--eq-space-3);
left: var(--eq-space-3);
margin: 0;
padding: var(--eq-space-1) var(--eq-space-2);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-danger);
font-size: var(--eq-text-s);
}
/* Range inputs: seek paints played, then buffered, then the rest. */
.eq-video-player-seek,
.eq-video-player-volume {
--_thumb: 12px;
--_track: 4px;
--_stop: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_played, 0));
--_load: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_loaded, 0));
appearance: none;
display: block;
flex: 1;
min-width: 0;
height: 20px;
margin: 0;
background: transparent;
cursor: pointer;
}
.eq-video-player-volume {
flex: 0 1 72px;
margin-inline: 0 var(--eq-space-1);
}
.eq-video-player-seek:focus-visible,
.eq-video-player-volume:focus-visible {
outline: none;
}
.eq-video-player-seek:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-video-player-seek::-webkit-slider-runnable-track,
.eq-video-player-volume::-webkit-slider-runnable-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
background:
linear-gradient(to right, var(--eq-solid) var(--_stop), transparent var(--_stop)),
linear-gradient(to right, color-mix(in srgb, var(--eq-ink) 30%, transparent) var(--_load), transparent var(--_load)),
var(--eq-tint-strong);
}
.eq-video-player-seek::-webkit-slider-thumb,
.eq-video-player-volume::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
margin-top: calc((var(--_track) - var(--_thumb)) / 2);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-video-player-seek:focus-visible::-webkit-slider-thumb,
.eq-video-player-volume:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-video-player-seek:active:not(:disabled)::-webkit-slider-thumb,
.eq-video-player-volume:active::-webkit-slider-thumb {
scale: 1.25;
}
.eq-video-player-seek::-moz-range-track,
.eq-video-player-volume::-moz-range-track {
height: var(--_track);
border-radius: var(--eq-radius-full);
background:
linear-gradient(to right, color-mix(in srgb, var(--eq-ink) 30%, transparent) var(--_load), transparent var(--_load)),
var(--eq-tint-strong);
}
.eq-video-player-seek::-moz-range-progress,
.eq-video-player-volume::-moz-range-progress {
height: var(--_track);
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
}
.eq-video-player-seek::-moz-range-thumb,
.eq-video-player-volume::-moz-range-thumb {
box-sizing: border-box;
width: var(--_thumb);
height: var(--_thumb);
border: 2px solid var(--eq-solid);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-video-player-seek:focus-visible::-moz-range-thumb,
.eq-video-player-volume:focus-visible::-moz-range-thumb {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Small frames keep play, time, captions and full screen; the rest have keys. */
@container (max-width: 420px) {
.eq-video-player-volume,
.eq-video-player-button[data-control="pip"] {
display: none;
}
.eq-video-player-cue {
font-size: var(--eq-text-s);
}
}
@container (max-width: 300px) {
/* A small frame shows only the poster and the big play button until playback starts. */
.eq-video-player:not([data-started]) .eq-video-player-bar {
display: none;
}
.eq-video-player-button[data-control="rate"],
.eq-video-player-button[data-control="mute"] {
display: none;
}
.eq-video-player-bar {
right: var(--eq-space-1);
bottom: var(--eq-space-1);
left: var(--eq-space-1);
}
.eq-video-player-big {
width: 40px;
height: 40px;
top: calc(50% - 16px);
}
.eq-video-player-captions {
right: var(--eq-space-2);
bottom: calc(var(--eq-h-s) + 38px);
left: var(--eq-space-2);
}
.eq-video-player-cue {
font-size: var(--eq-text-xs);
}
}
/* A spinner still reports that something is happening, so it slows down rather than stops. */
@media (prefers-reduced-motion: reduce) {
.eq-video-player-button[data-waiting]::after {
animation-duration: 2.4s;
}
}
}