Video Player Controls
Machine-coding brief for custom video controls — play/seek/volume, keyboard, Media Session hooks, and a11y.
intermediate4 min read
- machine-coding
- interview
- react
- media
- a11y
Problem statement
Build custom video player controls around a <video> element: play/pause, seek bar, time labels, mute/volume. Interviewers score syncing React state with media events, scrubbing UX, and keyboard accessibility — not HLS streaming (that’s system design).
Requirements
Must have
- Video element with
src - Play / pause toggle
- Seek range input bound to
currentTime/duration - Current time and duration display (
mm:ss) - Mute toggle
- Keyboard: Space play/pause when focused, arrows seek,
mmute
Should have
- Volume slider
- Click-on-video toggles play
- Loading / buffering indicator via
waiting/canplay - Hide native controls (
controlsattribute off)
Nice to have
- Fullscreen
- Playback rate
- Captions track
- Picture-in-picture
Planning (5 minutes out loud)
- Single video ref as source of truth for playback; React mirrors for UI
- Don’t
setStateeverytimeupdatewithout need — or accept it for interview - Seeking flag to avoid fight between user scrub and timeupdate
- MVP — play + seek + time; then volume + keys
- Autoplay policies — muted autoplay only
Architecture
VideoPlayer
├── video
├── Controls
│ ├── PlayButton
│ ├── TimeBar (input range)
│ ├── TimeLabel
│ ├── Volume
│ └── Fullscreen?
└── useVideoController
API
type VideoPlayerProps = {
src: string;
poster?: string;
captionsSrc?: string;
};
Implementation sketch
function formatTime(sec: number) {
if (!Number.isFinite(sec)) return "0:00";
const s = Math.floor(sec % 60);
const m = Math.floor(sec / 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function VideoPlayer({ src, poster }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [muted, setMuted] = useState(false);
const [seeking, setSeeking] = useState(false);
const [volume, setVolume] = useState(1);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onTime = () => {
if (!seeking) setTime(v.currentTime);
};
const onMeta = () => setDuration(v.duration);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
v.addEventListener("timeupdate", onTime);
v.addEventListener("loadedmetadata", onMeta);
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
return () => {
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("loadedmetadata", onMeta);
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
};
}, [seeking]);
function togglePlay() {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play();
else v.pause();
}
function onSeek(value: number) {
const v = videoRef.current;
if (!v) return;
v.currentTime = value;
setTime(value);
}
return (
<div
className="player"
onKeyDown={(e) => {
const v = videoRef.current;
if (!v) return;
if (e.key === " " || e.key === "k") {
e.preventDefault();
togglePlay();
}
if (e.key === "ArrowRight") {
v.currentTime = Math.min(duration, v.currentTime + 5);
}
if (e.key === "ArrowLeft") {
v.currentTime = Math.max(0, v.currentTime - 5);
}
if (e.key === "m") {
v.muted = !v.muted;
setMuted(v.muted);
}
}}
tabIndex={0}
role="region"
aria-label="Video player"
>
<video
ref={videoRef}
src={src}
poster={poster}
onClick={togglePlay}
playsInline
/>
<div className="controls">
<button
type="button"
aria-label={playing ? "Pause" : "Play"}
onClick={togglePlay}
>
{playing ? "Pause" : "Play"}
</button>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={time}
aria-label="Seek"
onPointerDown={() => setSeeking(true)}
onPointerUp={() => setSeeking(false)}
onChange={(e) => onSeek(Number(e.target.value))}
/>
<span>
{formatTime(time)} / {formatTime(duration)}
</span>
<button
type="button"
aria-label={muted ? "Unmute" : "Mute"}
aria-pressed={muted}
onClick={() => {
const v = videoRef.current!;
v.muted = !v.muted;
setMuted(v.muted);
}}
>
Mute
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
aria-label="Volume"
onChange={(e) => {
const vol = Number(e.target.value);
setVolume(vol);
videoRef.current!.volume = vol;
videoRef.current!.muted = vol === 0;
setMuted(vol === 0);
}}
/>
</div>
</div>
);
}
Fullscreen
function toggleFullscreen(root: HTMLElement) {
if (!document.fullscreenElement) void root.requestFullscreen();
else void document.exitFullscreen();
}
Accessibility essentials
- Named controls; pressed state for mute
- Seek and volume are labeled range inputs
- Keyboard operable from player region
- Captions button if tracks exist (
textTracks) - Don’t remove native controls without replacing equivalent a11y
Performance notes
timeupdatefrequency ~4Hz — OK; for progress UI only, rAF while playing is smoother- Avoid re-rendering heavy trees on each tick — isolate controls state
- Prefer progressive MP4 for interview demos
Footguns
- React state as truth fighting the media element
- Seek bar max=0 before metadata
- Space scrolling the page without preventDefault
- Autoplay without mute blocked by browsers
- Forgetting
playsInlineon iOS
Interview out-loud answer
The media element owns playback; React mirrors play state, time, and volume via events. A seeking flag prevents scrubbing jank. Controls are labeled buttons and ranges with keyboard shortcuts on the player region. Streaming, ABR, and DRM are out of scope unless expanded into system design.