ESC

Type to search the knowledge base.

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, m mute

Should have

  • Volume slider
  • Click-on-video toggles play
  • Loading / buffering indicator via waiting / canplay
  • Hide native controls (controls attribute off)

Nice to have

  • Fullscreen
  • Playback rate
  • Captions track
  • Picture-in-picture

Planning (5 minutes out loud)

  1. Single video ref as source of truth for playback; React mirrors for UI
  2. Don’t setState every timeupdate without need — or accept it for interview
  3. Seeking flag to avoid fight between user scrub and timeupdate
  4. MVP — play + seek + time; then volume + keys
  5. 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

  • timeupdate frequency ~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

  1. React state as truth fighting the media element
  2. Seek bar max=0 before metadata
  3. Space scrolling the page without preventDefault
  4. Autoplay without mute blocked by browsers
  5. Forgetting playsInline on 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.

Further reading