cf.completefrontend
My progress
SYSTEM DESIGN · #39

Design a video player

A reusable video player component with its own controls on top of the browser's video element: play, seek with preview frames, volume, speed, captions, quality, fullscreen, and picture-in-picture. The hard parts are turning a noisy stream of media events into one honest player state, building a seek bar and controls that work with a mouse, a finger, a keyboard, and a screen reader, and playing adaptive streams that survive bad networks.

The question

Design a video player component that product teams across the company can drop into any page: the course site, the marketing pages, and the main watch page. It should look the same in every browser, so the controls are ours, not the browser's.

Walk me through the controls, how the player knows what it's doing at any moment, how seeking and captions work, how you'd play adaptive streams, and how keyboard and screen reader users get the same player as everyone else. Tell me what the component's API looks like for the teams using it.

Real-life examples: YouTube watch page player, Vimeo embed player, Netflix web player, Course sites like Coursera and Udemy

Requirements: what to ask before drawing anything

A video player looks like a solved problem: the browser has a video element with a controls attribute, and it plays video. The interview is about everything that attribute doesn't give you: one look in every browser, a seek bar with preview frames, captions people can restyle, quality menus for adaptive streams, keyboard shortcuts that don't fight the page, and numbers that tell the business whether people could actually watch. Spend the first five minutes finding out which of these matter here.

Ask from the outside in: who embeds the player (one app or many teams), what kind of video (short clips, long lessons, live), which devices, which rules (accessibility law, DRM). The table shows the answers this guide assumes and what would move if the answer changed.

QuestionAnswer we assumeWhat it changes
Who uses the component?Many product teams in one company: course pages, marketing pages, the main watch page.The API must be small, stable, and themeable. Events and an imperative handle matter as much as the UI.
What content?On-demand video from 30 seconds to 3 hours, mostly 10 to 20 minute lessons. Live is a follow-up.Long videos make resume position, seek previews, and chapter markers worth building.
Which formats?Adaptive streams (HLS, with DASH possible) from our CDN, plus plain MP4 for short marketing clips.We need a streaming engine for most browsers and native HLS on Safari, behind one source API.
Which devices?Desktop browsers and phones about equally. iPhone Safari is a big share of mobile.Touch gestures, small screens, and iPhone's special rules for fullscreen and media.
Accessibility bar?WCAG 2.2 AA. Course content is sold to universities and governments that require it.Captions, caption styling, audio description tracks, keyboard control, and screen reader labels are requirements, not extras.
Protected content?Some paid courses use DRM.An EME path with license errors that need their own messages.
What do we measure?Startup time, rebuffering, errors, watch progress.An analytics layer that listens to the player and reports quality of experience.

Functional requirements

  • Play and pause, by button, by clicking the video, and by keyboard.
  • Seek by dragging or clicking a seek bar that shows how much is buffered and a preview frame with the time under the pointer.
  • Volume and mute, remembered between visits. Playback speed from 0.25x to 2x.
  • Captions and subtitles in several languages, with user styling (size, color, background).
  • Quality menu with an Auto option for adaptive streams.
  • Fullscreen and picture-in-picture.
  • Resume where the learner stopped, on any device.
  • Keyboard shortcuts in the style people know from YouTube: k or Space, j and l, arrows, f, m, c, digits.
  • A component API: sources, tracks, poster, events, an imperative handle, theming, and plugin slots.

Non-functional requirements, with numbers

  • Fast first paint: the poster image and a play button render with the page; the player's JavaScript must not hurt Largest Contentful Paint. Budget about 30 KB gzipped for the player shell, with the streaming engine (hls.js alone is well over 100 KB gzipped) loaded only when a stream actually starts.
  • Fast start: under 1 second from pressing play to the first frame on a good connection, under 2.5 seconds on a slow 4G one.
  • Few stalls: rebuffering under 1% of watch time at the 75th percentile.
  • Responsive controls: every press gives visible feedback within 100 ms, even while the video is still loading.
  • Accessible: every control reachable and usable by keyboard, every control named with its state, captions on for every course video.
  • Works everywhere we ship: the last two versions of Chrome, Edge, Firefox, Safari, and iOS Safari.

Out of scope, and why

  • Encoding, packaging, and the CDN: the server side of streaming. The YouTube guide covers it; we consume its output.
  • Live streaming with low latency: it changes buffering and the seek bar enough to be its own problem. We touch on it in the interview chapter.
  • Ads (VAST, VMAP, ad pods): a large topic of its own. We leave a plugin slot for it.
  • Recommendations, comments, and the watch page around the player.

Check your understanding · Exercise 1

Which answers from the interviewer would change this design the most? Select all that apply.

What makes a video player hard

Most components own their state: a dropdown is open because you set open to true. A video player does not. The real state lives inside the browser's media pipeline: a network stack downloading bytes, a decoder turning them into frames, and a clock moving the playhead.

Your UI only hears about that pipeline through events, and those events arrive late, in odd orders, and sometimes not at all. Five traits drive every decision in this guide.

  1. The truth is somewhere else. Pressing play does not mean the video plays. play() returns a promise that can reject because of autoplay rules, and even after it resolves the video can stop to wait for data. The UI must show what the media element is doing, not what the user asked for.
  2. Time is continuous, the UI is not. The playhead moves 30 to 60 frames a second, but timeupdate fires only every 15 to 250 ms (as rarely as 4 times a second), and a seek takes 50 ms on a fast connection or 3 seconds on a slow one. The seek bar must look smooth while the data under it is coarse and late.
  3. One player, four kinds of input. Mouse users hover and drag, touch users tap and double-tap, keyboard users press shortcuts, and screen reader users move through controls that speak. Each needs the same features, and they fight: a hover preview means nothing on touch, and a single-letter shortcut can steal a key a screen reader needs.
  4. The network decides quality. A 10 minute lesson at 1080p and 5 Mbps is about 375 MB. Nobody downloads that up front. Adaptive streaming fetches the video in 2 to 6 second segments and picks the quality per segment, so the player is also a small download scheduler.
  5. Browsers differ in ways that matter. iPhone Safari won't make a div fullscreen, and it gets Media Source Extensions only through a newer ManagedMediaSource API. Desktop Firefox gained the picture-in-picture APIs only in 2026, and Firefox on Android still lacks them. A component used by many teams must hide these differences behind one API.
Wireframe of the video player for Lesson 42: Closures. The top area is the video viewport, with a captions overlay at its lower left and a settings menu open at its right showing Speed 1.5x, Quality Auto, and Captions EN. Below the viewport runs the seek bar: a played part, a longer buffered part, and a preview tile reading 2:41 above the hover point. Under it is the control row: play, volume, the time 1:05 of 12:14, captions, settings, picture-in-picture, and fullscreen.Lesson 42: Closuresvideo viewportSpeed: 1.5xQuality: AutoCaptions: ENcaptions overlay2:41playvol1:05 / 12:14CCgearPiPfull
Everything sits inside one container: the viewport and its overlays, the seek bar, and the control row. That container, not the video, is what goes fullscreen, so all of it comes along.

The wireframe names the parts we'll design. The viewport is the video element plus overlays on top of it: the big play button, the buffering spinner, captions, and error messages. The control bar sits at the bottom: the seek bar across the full width, then a row of buttons.

The settings menu opens above the control bar for speed, quality, and caption options. Everything is inside one container element, and that container is what goes fullscreen.

Think first

A user clicks play. Your click handler sets isPlaying = true and the button flips to a pause icon. The video then waits 4 seconds for data. What does the user see, and what is wrong with the design?

Native controls or our own: and the media element API underneath

The naive answer is one line: video src=lesson.mp4 controls. It plays, it's accessible enough, and it's free. Then the requests arrive.

Design wants the same controls in Chrome, Safari, and Firefox, which all draw them differently. Product wants preview frames on the seek bar, a quality menu, chapter markers, and a speed menu with 1.75x. Accessibility wants caption styling that works the same everywhere. The native controls can't be extended: you get what the browser draws, and you can't add a button to it.

Decision How do we build the controls?

Native controls attribute

Add controls to the video element and let each browser draw its own UI.

Pros

  • Zero code, zero bundle.
  • Keyboard and screen reader support from the browser.
  • Native fullscreen and picture-in-picture buttons for free.

Cons

  • Looks different in every browser and can't be themed.
  • No place for quality menus, preview thumbnails, chapters, or custom buttons.
  • Caption styling and shortcuts depend on the browser.

Own controls over a native video element

Keep the video element for decoding and drawing frames.

Hide native controls and draw our own buttons and sliders in a container on top.

Drive the UI from media events, and drive the video through its methods (play(), pause(), currentTime).

Pros

  • Full control of look, features, and behavior.
  • The browser still does the hard parts: decoding, A/V sync, hardware acceleration, DRM.
  • Works with streaming engines, which also attach to the native element.

Cons

  • We now own accessibility, keyboard handling, and every browser quirk.
  • More code to maintain: about 20 to 30 KB gzipped for a full control set.

Adopt an open-source player (Video.js, Vidstack, Media Chrome)

Use a library that already implements custom controls over the native element, and theme it.

Pros

  • Years of fixed browser bugs.
  • Plugin systems and streaming integrations exist.

Cons

  • Their state model and API become ours; deep changes mean forking.
  • Bundle size and update cadence are someone else's.
  • In an interview, it skips the design they want to hear.

Decode and draw ourselves (WebCodecs plus canvas)

Fetch and demux the stream, decode frames with WebCodecs, draw them to a canvas, and sync audio by hand.

Pros

  • Total control, frame-exact effects.

Cons

  • We rebuild A/V sync, buffering, and power-efficient rendering.
  • No DRM path, no native picture-in-picture, no AirPlay.
  • Far too much for a product player.

What we pick: Own controls over a native video element. The browser keeps doing what only it does well (decoding, syncing, drawing, protecting content), and we own everything the user touches. In a real company, look hard at Media Chrome or Vidstack as a base: they follow exactly this model. In the interview, design it yourself and mention them.

The media element API you build on

The HTMLMediaElement interface (shared by video and audio) is the contract. You command it with methods and properties, and it reports back with events. Three parts deserve close reading.

play() returns a promise. It resolves when playback actually starts. It rejects with NotAllowedError when the browser blocks playback, usually because the video has sound and there was no user gesture, and with AbortError when a pause() or a new source interrupts it. Code that calls play() and ignores the result shows a pause icon over a video that never started, and logs an unhandled rejection.

ts
async function requestPlay(video: HTMLVideoElement) {
  try {
    await video.play();
  } catch (err) {
    if (err instanceof DOMException && err.name === "NotAllowedError") {
      // Autoplay with sound blocked: show the big play button again.
      emit({ type: "PLAY_BLOCKED" });
    } else if (err instanceof DOMException && err.name === "AbortError") {
      // Interrupted by pause() or a new source: not an error for the user.
    } else {
      emit({ type: "ERROR", error: err });
    }
  }
}
Always handle the play() promise. An interrupted play is normal; a blocked one needs UI.

Two numbers describe readiness. readyState says how much data is ready at the playhead, from 0 to 4. networkState says what the network side is doing. Together they answer the question every UI asks: can we play right now?

networkState is 0 NETWORK_EMPTY, 1 NETWORK_IDLE, 2 NETWORK_LOADING, or 3 NETWORK_NO_SOURCE (no usable source found).
ValuereadyState meaningWhat the UI does
0 HAVE_NOTHINGNothing known yet.Show the poster and a play button.
1 HAVE_METADATADuration and size known, no frame yet.Show total time, enable the seek bar.
2 HAVE_CURRENT_DATAThe frame at the playhead, but not the next one.A frame is visible; playback would stall at once.
3 HAVE_FUTURE_DATAA little ahead of the playhead.Can start, may stall soon.
4 HAVE_ENOUGH_DATAThe browser thinks it can play to the end at the current rate.Safe to hide the spinner.

Events tell the story. About twenty media events exist, and a player listens to about fifteen of them. The timeline shows the order you'll usually see them in when a user loads a video, presses play, and seeks.

Media events in the order they usually fire, in three phases, time running left to right. Page load: loadstart at readyState 0, loadedmetadata at readyState 1, loadeddata at readyState 2, canplay at readyState 3 or more. Press play: play when paused becomes false, waiting while data is needed, playing when frames move, then timeupdate about 4 times a second. Seek to 5:00: seeking with the target, waiting while the segment is fetched, seeked when the frame is ready, and playing as playback resumes.time runs left to rightreadyStatepage loadloadstart0loadedmetadata1loadeddata2canplay3 or morepress playplaypaused=falsewaitingneed dataplayingframes movetimeupdate~4 per secseek to 5:00seekingtarget 5:00waitingfetch segmentseekedframe readyplayingresumes
play means a request was accepted; playing means frames are moving. A seek outside the buffer adds a waiting between seeking and seeked, which belongs to the seek, not to a stall.
EventWhen it firesWhat the player does with it
loadstartThe browser began loading a new source.Reset time, buffered ranges, and errors from the previous source.
loadedmetadataDuration and dimensions are known (readyState 1).Show duration, size the seek bar, restore a saved position.
loadeddataThe frame at the playhead is available (readyState 2).A first frame can replace the poster if you want one.
canplayEnough data to start (readyState 3 or more).End the loading state.
canplaythroughThe browser estimates it can play to the end without stopping (readyState 4).Rarely needed. With an MSE engine the estimate means little, because the engine decides what to fetch.
playpaused became false (a request, not a frame yet).Flip the button to pause.
playingFrames are actually moving, also after a stall.Hide the spinner.
waitingPlayback stopped because the next frame isn't loaded.Show the spinner after a short delay; start a rebuffer timer.
timeupdateThe playhead moved; every 15 to 250 ms, so about 4 to 66 times a second.Update the time label and seek bar (if not scrubbing).
progressMore bytes arrived.Redraw the buffered ranges.
seeking and seekedA seek started and finished.Show and hide the seek spinner; count seek time.
endedThe playhead reached the end.Show replay or the next video.
errorLoading or decoding failed; video.error.code says why.Show an error with a retry action.
volumechange, ratechangeVolume, mute, or speed changed.Update the controls and save the preference.

Check your understanding · Exercise 2

A user presses play on a video with sound, but the call came from a setTimeout 3 seconds after the page loaded, not from the click. Which statements are true? Select all that apply.

The player as a state machine

The naive model is a bag of booleans: isPlaying, isLoading, isSeeking, isEnded, hasError. Five booleans allow 32 combinations, and most are nonsense. Can the player be isPlaying and isEnded? isSeeking and hasError?

Each event handler flips one or two flags, and after a few months of bug fixes nobody knows which combinations are reachable. The classic symptom: the spinner stays on forever after a seek, because seeked cleared isSeeking but nothing cleared the isLoading that waiting set during the seek.

Decision How does the player know what it is doing?

Independent booleans set by handlers

Each event handler sets one or two flags; the UI reads them.

Pros

  • Fast to write for the first version.

Cons

  • Impossible combinations are representable, so they happen.
  • Order bugs: a late timeupdate or waiting overrides newer truth.
  • Hard to test: you test handlers, not behaviors.

Read the element on every render

Store nothing. On each event, re-render and read paused, seeking, ended, readyState, error from the element.

Pros

  • Never out of sync with the element.
  • Very little code.

Cons

  • The element can't tell you about intent: a blocked play(), a pending quality switch, or a retry in progress.
  • Buffering is a guess from readyState, which flickers.
  • Hard to add timing rules such as showing the spinner only after 300 ms.

An explicit state machine fed by media events

One status value from a closed set, plus a few fields (time, buffered, volume).

Media events and user actions become typed events sent to a pure reducer.

The reducer decides the next state; illegal transitions are ignored and logged.

Pros

  • Impossible states can't be represented.
  • Every transition is a line in a table you can test without a browser.
  • Analytics hang off transitions: entering buffering starts a rebuffer timer.

Cons

  • More up-front design.
  • You must still read the element in a few places (for example after a source change).

What we pick: An explicit state machine in a plain reducer, with the element's own properties used as a cross-check on loadedmetadata, seeked, and error. A library like XState is fine but not needed for about 10 states. The state-machines lesson covers the general technique; here we apply it.

The states and what each one shows

StateMeaningMain buttonOverlay
idlePoster shown, nothing loaded (with preload=none).PlayBig play button on the poster.
loadingUser asked to play; metadata or first frames are on the way.PauseSpinner after 300 ms.
pausedReady and stopped.PlayNone, or the big play button if never started.
playingFrames are moving.PauseNone.
bufferingWas playing, ran out of data (waiting).PauseSpinner after 300 ms.
seekingA seek is in progress; remembers whether to resume.Play or pause, as before the seekSpinner after 300 ms; seek bar shows the target.
endedReached the end.ReplayReplay or next-video card.
errorFailed after retries.RetryMessage with a retry button.
The player state machine. idle goes to loading on play; loading goes to playing on the playing event. playing goes to buffering on waiting, and buffering back to playing on playing. playing goes to paused on pause, and paused goes to loading on play. paused goes to seeking on seeking, and seeking returns to paused on seeked when resume is off, or to playing when resume is on. playing goes to ended on ended. paused goes to error on an error event; any state can reach error, and replay from ended goes to loading.idleloadingplayingseekingpausedbufferingerrorendedplayplayingwaitingplayingpauseplayseekingseekedseeked, resumeendederrorany state: errorended + play: loadingresume set on entry
Each box is one value of status, so impossible mixes like playing and ended can't exist. Seeking remembers whether to resume, which is how one seeking state serves both paused and playing users.

Seeking needs memory. A user who seeks while playing expects playback to continue; one who seeks while paused expects to stay paused. So seeking carries a resume flag set from the state it came from, and seeked returns to playing or paused based on it. The same idea handles buffering: waiting during a seek is part of the seek, not a separate rebuffer, so it must not start the rebuffer timer.

ts
type Status = "idle" | "loading" | "paused" | "playing" | "buffering" | "seeking" | "ended" | "error";
type State = { status: Status; resume: boolean; time: number };

function reduce(s: State, e: PlayerEvent): State {
  const is = (...list: Status[]) => list.includes(s.status);
  switch (e.type) {
    case "play":
      if (is("seeking")) return { ...s, resume: true };
      return is("idle", "paused", "ended") ? { ...s, status: "loading" } : s;
    case "playing":
      return is("loading", "buffering", "paused") ? { ...s, status: "playing" } : s;
    case "waiting":
      return is("playing") ? { ...s, status: "buffering" } : s;
    case "seeking":
      if (is("error", "idle", "seeking")) return s;
      return { ...s, status: "seeking", resume: is("playing", "buffering", "loading") };
    case "seeked":
      if (!is("seeking")) return s;
      return { ...s, status: s.resume ? "playing" : "paused" };
    case "pause":
      if (is("seeking")) return { ...s, resume: false };
      return is("playing", "buffering", "loading") ? { ...s, status: "paused" } : s;
    case "ended":
      return is("playing", "buffering") ? { ...s, status: "ended", resume: false } : s;
    case "error":
      return { ...s, status: "error" };
  }
  return s;
}
The core of the reducer, the same table the lab below runs. Each case says which states an event can move, and every other state ignores it.

Note the seeked case: it only acts during a seek, and if the user was playing, it goes to playing even though frames might not be decoded yet. If data is missing, the element fires waiting right after, and the machine moves to buffering. Small steps, each one true for a moment, beat one big guess.

Timing rules: when to show the spinner

Most waiting events on a good connection last under 200 ms: a seek inside the buffer, a quality switch. Showing a spinner for each makes the player look broken. Show it only after the machine has been in loading, buffering, or seeking for 300 ms, and keep it at least 500 ms once shown so it doesn't flash. The timer belongs to the view layer, keyed on the state; the reducer stays pure.

Fire

The poster is showing. Fire events in any order, for example play, playing, waiting.

Player state lab. Status is idle, resume is off, rebuffers counted: 0. The main button reads Play and the overlay shows big play on poster. Last events: none.statusidleloadingpausedplayingbufferingseekingendederrorwhat the player showsPlaybig play on posterresume: offrebuffers: 0last: nonelast eventsgreen moved the machine, crossed out was ignored
The poster is showing. Fire events in any order, for example play, playing, waiting.

Try the lab: fire waiting while paused (nothing happens, which is correct), seek while playing and then fire waiting (still seeking, no rebuffer counted), then fire seeked. Fire events in a silly order and watch the machine refuse transitions it doesn't allow.

Check your understanding · Exercise 3

The player is paused at 1:00. The user drags the seek bar to 5:00, then presses play before seeked arrives. Which outcomes should the machine produce? Select all that apply.

The seek bar: buffered ranges, scrubbing, and preview frames

The seek bar is the most used and most subtle control. It shows four things at once: where the playhead is, how much is buffered ahead, where the pointer is hovering (with a preview frame and time), and, for long videos, chapter boundaries. It accepts mouse clicks, drags, touch drags, and keys. And it must not fight the video: while the user drags, timeupdate keeps firing with the old time and would yank the thumb back if we let it.

Decision What element is the seek bar?

A styled native range input

input type=range with min=0, max=duration, step=any, styled through its track and thumb pseudo-elements.

Buffered ranges and hover preview drawn in sibling elements underneath.

Pros

  • Keyboard, focus, and slider semantics for free.
  • Works with every screen reader and with voice control.

Cons

  • Styling the track and thumb takes separate vendor pseudo-elements, and some details (like the filled part) need workarounds.
  • Pointer events go to the input, so hover preview and drag tracking need care.
  • The input's own value updates fight timeupdate unless you guard them.

A custom element with the slider role

A div with role=slider, tabindex=0, aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext.

Pointer events with pointer capture for dragging; key handlers for arrows, Page Up and Down, Home, End.

Pros

  • Full control of layers: buffered, played, hover, chapters, thumb.
  • One pointer model for mouse, pen, and touch.

Cons

  • We implement the keyboard model and ARIA ourselves, following the APG slider pattern.
  • Mistakes are invisible to sighted mouse users, so they need tests.

What we pick: The custom slider, built to the APG slider pattern (see the aria-patterns lesson), because the seek bar is really a stack of layers that a native range can't hold. If the team is small, a styled range input with the layers drawn behind it is a fine first version: it gets accessibility right by default.

Drawing buffered ranges

video.buffered is a TimeRanges object: a list of start and end times the browser holds. It is usually one range, but after a seek it can be several: 0 to 40 seconds from the start, then 300 to 330 seconds from where the user jumped. Draw every range, not only the one at the playhead. On each progress event (and after seeked), read the ranges and convert each to left and width percentages of the duration.

ts
function bufferedSegments(video: HTMLVideoElement) {
  const { buffered, duration } = video;
  if (!Number.isFinite(duration) || duration === 0) return [];
  const out: { left: number; width: number }[] = [];
  for (let i = 0; i < buffered.length; i++) {
    const start = buffered.start(i);
    const end = buffered.end(i);
    out.push({ left: (start / duration) * 100, width: ((end - start) / duration) * 100 });
  }
  return out;
}
Duration is NaN before metadata and Infinity for some live streams, so guard it.

Scrubbing with pointer capture

On pointerdown, call setPointerCapture(e.pointerId) on the bar. From then on, every pointermove and the final pointerup go to the bar even when the pointer leaves it, so a fast drag off the edge doesn't get lost. Set the CSS touch-action: none on the bar so a horizontal finger drag isn't taken by the browser for scrolling. Handle pointercancel (a phone call, a system gesture) by ending the drag without seeking.

While dragging, the thumb follows the pointer, not currentTime. Should the video seek on every move? A pointermove can fire 60 to 120 times a second, and each currentTime write starts a seek that may fetch a segment. Seeking on release only is cheap but feels dead.

The middle path: show the preview frame during the drag, and seek at most every 200 ms if the target is inside the buffer (cheap and smooth), else only on release. Firefox and Safari also offer fastSeek(), which jumps to the nearest keyframe for quicker but less exact previews; Chromium browsers don't have it, so feature-detect.

Sequence diagram of scrubbing, across User, SeekBar, Controller, Media, and CDN. The user presses on the seek bar at 3:00; the seek bar captures the pointer and shows the thumb and the preview tile for 3:00. The user releases at 5:00; the seek bar calls seek 300 on the controller, which sets currentTime to 300 on the media element. The media side (the element and its engine) reports seeking, then requests the segment for 5:00 from the CDN, which fails with 503. The engine retries after 1 second and gets 200 with 1.8 MB. The element reports seeked to the controller, which resumes and shows the seek bar playing at 5:00.UserSeekBarControllerMediaCDNpointerdowntile for 3:00up at 5:00seek(300)currentTimeseekingGET seg 5:00503retry in 1 s200, 1.8 MBseekedplaying, 5:00
During the drag only the seek bar changes; one seek() goes out on release. The failed request never reaches the user as an error: the engine retries, and the user sees a seek spinner until seeked.

Preview frames on hover

Decision Where do the hover preview frames come from?

A second hidden video element

Load the same video in a hidden element and seek it to the hover time; draw its frame.

Pros

  • Exact frames, no server work.

Cons

  • Each hover is a real seek and often a segment download (hundreds of KB).
  • A second decoder, which phones have few of.
  • Slow: the frame lags the pointer by hundreds of ms.

One image per interval

The server writes a small JPEG every 5 seconds; the client loads the one under the pointer.

Pros

  • Simple, cacheable.

Cons

  • A request per hover position; a 20 minute video has 240 images.
  • Hovering across the bar fires dozens of requests.

Sprite sheets described by a WebVTT thumbnails file

The server packs frames into grid images, for example 10 by 10 tiles of 160 by 90 pixels.

A WebVTT file maps each time range to an image and a rectangle using the #xywh= media fragment.

The client finds the cue for the hover time and shows that tile with CSS background position.

Pros

  • One request covers 100 frames (500 seconds at one frame per 5 seconds).
  • A format players already share (Video.js plugins, Vidstack, JW Player).
  • Easy to load lazily on the first hover.

Cons

  • Low resolution and only one frame per interval.
  • An extra packaging step on the server.

What we pick: Sprite sheets described by a WebVTT file. For a 20 minute lesson that's 240 frames in 3 sheets of roughly 100 to 200 KB each, fetched on the first hover, never on page load. Parse the VTT once into a sorted array and binary search it on each move.

text
WEBVTT

00:00:00.000 --> 00:00:05.000
thumbs-0.jpg#xywh=0,0,160,90

00:00:05.000 --> 00:00:10.000
thumbs-0.jpg#xywh=160,0,160,90

00:08:20.000 --> 00:08:25.000
thumbs-1.jpg#xywh=0,0,160,90
A thumbnails track. Tile 100 starts a new sheet, at 500 seconds (8:20).
A thumbnail sprite sheet, thumbs-0.jpg, drawn as a grid of 10 columns; the first 4 rows are shown, each tile 160 by 90 pixels and 5 seconds of video. Hovering at 3:00 means 180 seconds, divided by 5 is tile 36, which is row 3, column 6, highlighted. Its rectangle is xywh 960, 270, 160, 90, and the preview shows that tile with the time 3:00.thumbs-0.jpg: 10 x 10 tilesrows 4 to 9 not shownhover at 3:00 = 180 s180 / 5 = tile 36row 3, column 6xywh=960,270,160,903:00the preview tile
One image holds 100 frames. The player finds the cue for the hover time, then shows only that rectangle of the sheet with CSS background position, so hovering across the bar costs no extra requests.

Keyboard and screen reader behavior

When the seek bar has focus, Left and Right move 5 seconds, Page Up and Page Down move 10% (or 60 seconds), Home goes to the start and End to the end. Up and Down step it like Right and Left, as the APG slider pattern expects. YouTube maps Up and Down on its seek bar to volume instead; either works if the help text says so.

The slider exposes aria-valuemin=0, aria-valuemax as the duration in seconds, aria-valuenow as the current second, and aria-valuetext in words, for example 1 minute 5 seconds of 12 minutes 14 seconds. Without aria-valuetext, a screen reader says 65, which means nothing. Update these attributes about once a second while playing, not on every timeupdate, so assistive technology isn't flooded.

Buffer
Keys

Move the hover point, press keys, or click to seek.

Seek bar for a 12:14 video. Buffered ranges: 0:00 to 0:40 and 5:00 to 5:30. The playhead is at 1:05. Hovering at 3:00 shows tile 36 of sheet 0, row 3, column 6, and that time is not buffered. aria-valuenow is 65 and aria-valuetext is 1 minute 5 seconds of 12 minutes 14 seconds.3:00thumbs-0.jpg, tile 360:0012:14hatched: buffered, solid: playedxywh=960,270,160,90hover needs a fetchplayhead 1:05aria-valuenow=65aria-valuetext:1 minute 5 secondsof 12 minutes 14 seconds
Move the hover point, press keys, or click to seek.

Check your understanding · Exercise 4

A user watched 0:00 to 0:40, then jumped to 5:00 and watched to 5:30. They hover at 3:00. Which statements are true? Select all that apply.

Volume, speed, menus, and controls that hide themselves

The rest of the control bar looks like plain buttons, but each has a trap. Volume has a mute state separate from the level, and iPhones ignore the level entirely. Speed needs a menu that keyboard users can operate. And the whole bar must get out of the way while people watch, without vanishing from under a keyboard user's focus.

Volume and mute, remembered

The element has two separate properties: volume from 0 to 1 and muted true or false. Keep them separate in the UI too. Muting must not set volume to 0, or unmuting has nothing to return to.

When the user drags volume to 0, show the muted icon; when they drag it up from 0 while muted, unmute. Save both to localStorage on volumechange (debounced to about 500 ms while dragging) and restore them when the player mounts, so a learner who likes 40% doesn't reset it every lesson.

On iPhone and iPad, the hardware buttons own the volume: Safari treats volume as read-only and it always reads 1. Detect this once (set volume to 0.5 and read it back) and hide the slider, keeping only the mute button. The volume slider itself follows the same slider pattern as the seek bar, with aria-valuetext like 40 percent, and Up and Down arrows step it by 5%.

Playback speed

Set playbackRate to one of 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2. Browsers keep the pitch natural by default (preservesPitch is true), so 1.5x lectures don't sound like cartoons. Persist the choice per user: learners who watch at 1.5x watch everything at 1.5x. Note that the playhead now moves faster than the wall clock, which matters for analytics: 10 minutes of content watched at 2x is 5 minutes of watch time.

Speed, quality, and captions live in one settings menu. Use the menu button pattern: the gear is a button with aria-haspopup=menu and aria-expanded, and the menu uses role=menu with menuitemradio items carrying aria-checked. Arrow keys move between items, Enter picks, and Escape closes and returns focus to the gear.

Submenus (Settings, then Speed) slide in place and keep the same keyboard rules. The focus-management lesson covers returning focus correctly.

Auto-hiding controls

Decision When should the controls hide?

Never hide

The control bar stays visible over the video.

Pros

  • Nothing to get wrong; always reachable.

Cons

  • Covers the bottom 10 to 15% of the picture, often where slides and captions are.
  • Feels unpolished in fullscreen.

Hide after a fixed timer

Show on any mouse move; hide 3 seconds later, always.

Pros

  • Simple.

Cons

  • Hides while a keyboard user is tabbing through buttons: their focus ring disappears into nothing.
  • Hides while a menu is open or while the user is hovering the bar.
  • Hides while paused, when people most want the controls.

Hide on inactivity, with conditions

Show on pointer move, tap, or any key in the player; hide after about 3 seconds of no activity.

Never hide while paused, ended, or in error; while a menu is open; while the pointer is over the control bar; or while focus is inside the control bar.

Pros

  • Clean picture while watching.
  • Keyboard and screen reader users never lose the control they're on.

Cons

  • Several rules to get right and test.
  • Captions must move up when controls show, or the bar covers them.

What we pick: Hide on inactivity with conditions. The focus rule is the one teams forget: while focus is on a control, the bar stays. Also hide the cursor over the video when controls hide, and move captions up when they show.

Ten seconds of a keyboard user's session. The mouse moves at 0 seconds, then the user tabs into the control bar at 5 seconds and keeps focus there, reading the menu, past 8 seconds. With a fixed 3 second timer, the controls show from 0 to 3, hide from 3 to 5, show from 5 to 8, then hide at 8 while focus is still inside, so focus is lost. With the conditional rules, the controls show from 0 to 3, hide from 3 to 5, and stay visible from 5 to 10 because focus is inside the bar.eventsmouse moveTab into barstill focusedfixed 3 sfocus lostwith ruleskept: focus inside0 s3 s5 s8 s10 ssolid: controls shown, hatched: hidden
The fixed timer hides the bar under the user's focus at 8 seconds. The conditional rule keeps it while focus is inside, while paused, and while a menu is open.

Check your understanding · Exercise 5

Which behaviors are bugs in an auto-hiding control bar? Select all that apply.

Keyboard shortcuts that don't fight the page

People who watch a lot of video know YouTube's keys by heart, so match them. The question isn't which keys; it's where they listen. A player that grabs the k key on the whole document breaks the comment box under it, and a course page with three players can't have all three answering Space.

KeyActionNote
Space or kPlay or pauseSpace on a focused button already clicks it; don't toggle twice.
j and lBack and forward 10 secondsShows a -10 or +10 bubble over the video.
Left and RightBack and forward 5 secondsWhen the seek bar has focus, its own slider keys apply.
Up and DownVolume up and down 5%When the seek bar or an open menu has focus, their own keys apply instead.
fToggle fullscreenFullscreen goes on the player container, not the video.
mToggle muteKeeps the volume level for unmuting.
cToggle captionsTurns on the last used language.
0 to 9Jump to 0% to 90% of the video5 jumps to the middle.
< and > (Shift with comma or period)Slower or faster playbackSteps through the speed list, for example 1x to 1.25x.
, and . while pausedStep back or forward one frameApproximate: about 1/30 second.
EscapeClose a menuThe browser itself exits fullscreen on Escape.

Decision Where do shortcuts listen?

On the whole document

One keydown listener on document handles every key, wherever focus is.

Pros

  • Works the moment the page loads, like YouTube's watch page.

Cons

  • Steals letters from text fields and other widgets unless every case is excluded.
  • Two players on a page fight over the same keys.
  • Single-letter shortcuts active everywhere fail WCAG 2.1.4 unless users can turn them off or remap them.

Only when focus is inside the player

The listener sits on the player container; keys work when focus is on the container or any control in it.

Clicking the video focuses the container, so mouse users get shortcuts after the first click.

Pros

  • No conflicts with the page or other players.
  • Meets WCAG 2.1.4 through its 'active only on focus' exception.

Cons

  • Keys do nothing until the player has focus once.

Scoped, plus an opt-in page mode

Default to focus scoping. A prop shortcuts="page" lets a page with one main player (the watch page) listen on the document too, with a setting to turn single-letter keys off.

Pros

  • Safe default for embeds and course pages.
  • The watch page keeps the YouTube feel.

Cons

  • Two modes to test.

What we pick: Scoped by default, with an opt-in page mode. In both modes, ignore the key when the target is an input, textarea, select, or contenteditable, or when Ctrl, Alt, or Meta is held (those belong to the browser and the OS).

ts
function onKeyDown(e: KeyboardEvent) {
  if (e.ctrlKey || e.metaKey || e.altKey || e.defaultPrevented) return;
  const t = e.target as HTMLElement;
  if (t.closest("input, textarea, select, [contenteditable=true]")) return;
  if (t.closest("[role=slider], [role=menu]") && /^Arrow|^Page|Home|End/.test(e.key)) return;
  if (e.key === " " && t.closest("button")) return; // the button handles it
  const action = keymap[e.key.length === 1 ? e.key.toLowerCase() : e.key];
  if (!action) return;
  e.preventDefault();
  action(player);
  announce(player.describe(action)); // "Volume 60 percent", "Forward 10 seconds"
}
Ignore keys that belong to text fields, sliders, menus, buttons, the browser, and the OS.

Screen readers and single-letter keys

Screen readers on Windows (NVDA, JAWS) have a browse mode where single letters move through the page; in NVDA, for example, k jumps to the next link and f to the next form field. In browse mode those keys never reach the page, so our shortcuts don't fire, and that's correct. When the user tabs onto a control, the screen reader usually switches to focus mode and keys pass through.

Two rules follow. First, every shortcut must also exist as a button, because shortcuts are an extra, not the only way. Second, never put role=application on the player to force keys through: it switches off the screen reader's own navigation for everything inside.

A shortcut changes things without moving focus, so nothing is announced. Send a short message to a polite live region (Volume 60 percent, Captions on, Forward 10 seconds) and show the same text briefly over the video, the way YouTube shows a +10 bubble. The keyboard-navigation lesson covers roving focus inside the control bar.

Check your understanding · Exercise 6

Two players sit on a course page. Focus is on the play button of the second one. The user presses Space once. What should happen? Select all that apply.

Captions, subtitles, and audio descriptions

For a course platform sold to universities, captions are a legal requirement, not a feature. WCAG 2.2 success criterion 1.2.2 (level A) requires captions for prerecorded audio in video, and 1.2.5 (level AA) requires audio description for prerecorded video.

Two terms first. Captions are for viewers who can't hear the audio: they include speech plus sounds like [door slams] and speaker names. Subtitles translate speech for viewers who can hear but don't know the language. Both use the same format and the same code path.

WebVTT tracks and the textTracks API

Each caption file is a WebVTT file, attached as a track child of the video with kind (captions, subtitles, descriptions, chapters, or metadata), srclang, and label. A cue is a time range plus text, with optional settings for position and alignment. Streaming engines add caption tracks from the manifest the same way, so one code path covers both.

In script, video.textTracks lists the tracks. Each has a mode: showing (the browser draws it), hidden (loaded and firing events, not drawn), or disabled (inactive, so no cues and no events). A track fires cuechange when its activeCues change.

Decision Who draws the captions?

The browser, styled with ::cue

Set the chosen track to showing. Style with the ::cue pseudo-element.

Pros

  • No code. Works in native fullscreen on iPhone and inside picture-in-picture windows.
  • Respects the operating system's caption preferences in Safari.

Cons

  • ::cue accepts only a few properties (color, background, font, text shadow, outline, opacity, and a few more), and browsers draw position and padding differently.
  • Native captions don't know about our controls, so the bar can cover them.

Our own overlay

Set the track to hidden so it still fires cuechange.

On each change, render activeCues as text in an absolutely positioned layer above the video, honoring the cue's line and alignment.

Apply user styles from the caption settings menu.

Pros

  • Identical in every browser.
  • Moves up when the controls show.
  • Full user styling: font, size, colors, opacity, edges.

Cons

  • We must parse cue settings and handle overlapping cues.
  • Our overlay is invisible in iPhone native fullscreen, and may not appear in picture-in-picture.

What we pick: Our own overlay by default, switching the track to showing whenever our DOM can't be seen: iPhone native fullscreen (listen for webkitbeginfullscreen and webkitendfullscreen on the video) and video picture-in-picture. Never have both on at once, or viewers read every line twice.

ts
function useCaptions(video: HTMLVideoElement, lang: string | null, native: boolean) {
  const [lines, setLines] = useState<string[]>([]);
  useEffect(() => {
    const tracks = Array.from(video.textTracks);
    const track = tracks.find((t) => t.language === lang && (t.kind === "captions" || t.kind === "subtitles"));
    for (const t of tracks) if (t !== track && t.kind !== "metadata") t.mode = "disabled";
    if (!track) return setLines([]);
    track.mode = native ? "showing" : "hidden";
    const onChange = () => setLines(Array.from(track.activeCues ?? [], (c) => (c as VTTCue).text));
    track.addEventListener("cuechange", onChange);
    onChange();
    return () => track.removeEventListener("cuechange", onChange);
  }, [video, lang, native]);
  return native ? [] : lines;
}
Custom rendering keeps the track hidden but live. In native mode the browser draws and we render nothing.
Two caption paths. Normally, en.vtt from the CDN loads into a TextTrack with mode hidden, whose cuechange events feed our overlay, which applies user styles and moves up when controls show. In iPhone fullscreen or video picture-in-picture, the player switches the same TextTrack to mode showing, and the browser's native captions draw it with ::cue and the operating system's styles. Never both at once.en.vttTextTrackmode = hiddenour overlayuser styles, moves upcuechangeswitch modeiPhone fullscreenor video PiPTextTrackmode = showingnative captions::cue, OS stylesnever both: every line would show twice
One track, two renderers. hidden keeps cues flowing to our overlay; showing hands drawing to the browser where our DOM is not visible.

Caption styling that users control

A good checklist comes from the US FCC rules for devices and software that play TV programming (47 CFR 79.103). They list what viewers must be able to change: font, text size, text color and opacity, background color and opacity, edge style (uniform outline, drop shadow, raised, depressed), and the color of the caption window behind the text. Even where those rules don't apply, they are what viewers expect.

The caption settings menu offers exactly those, stored per user and applied as CSS custom properties on the overlay. Defaults: white text on a black background at 75% opacity, about 4.5% of the player height so it scales with the player and grows in fullscreen. Offer sizes from 50% to 200%. The visual-accessibility lesson covers contrast rules.

Audio descriptions

Audio description narrates what's on screen (a diagram appears, the instructor points at line 12) in the gaps between speech. The cleanest delivery is a second audio rendition in the stream: the HLS manifest lists an English audio track and an English described audio track, and the player's audio menu switches between them without reloading.

A text track with kind=descriptions read aloud by a screen reader is an alternative for small libraries, but browsers don't voice it on their own, so the player would announce each cue in a live region. For short marketing clips, a separate described version of the video is often simplest.

Check your understanding · Exercise 7

Our player renders captions in a custom overlay. Which situations need the native track switched to showing? Select all that apply.

Adaptive streaming behind one source API

A plain MP4 is one file at one quality. On a train, a 1080p file stalls; on fiber, a 480p file looks soft. Adaptive streaming fixes this: the server encodes the video at several qualities (a ladder, for example 240p to 1080p), cuts each into segments of 2 to 6 seconds, and lists them in a manifest (.m3u8 for HLS, .mpd for DASH).

The player picks a quality for each segment based on measured bandwidth and how much is buffered. This is adaptive bitrate (ABR) streaming. The YouTube guide covers the ladder, codecs, and the ABR algorithms in depth; here we care about how the component plays these streams in every browser.

Browsers don't read manifests themselves, except Safari, which plays HLS natively. Everywhere else, a JavaScript engine (hls.js, Shaka Player, or dash.js) downloads the manifest and segments and appends the bytes to the video through Media Source Extensions (MSE): a MediaSource object becomes the video's source, and SourceBuffers accept chunks of media.

On iPhone, plain MSE is not available. Since iOS 17.1, Safari offers ManagedMediaSource, a variant where the browser may evict buffered data to save memory and power, and hls.js supports it. It only opens when the video sets disableRemotePlayback or also lists an HLS source that AirPlay can use instead.

Decision How does the player play a stream?

Progressive MP4 only

Set src to an MP4 file with a few quality versions; let the user pick one.

Pros

  • No engine, works everywhere, simplest caching.

Cons

  • No automatic quality switching, so stalls on weak networks.
  • Wasteful: a viewer who quits after 30 seconds may already have downloaded megabytes.

HLS everywhere, native where available, engine elsewhere

If the browser plays HLS natively (Safari), set src to the manifest.

Otherwise, if MSE or ManagedMediaSource exists, load hls.js and attach it to the video.

Pros

  • One packaging format for the whole library.
  • Native HLS on Apple devices keeps AirPlay, FairPlay DRM, and battery-friendly decoding.

Cons

  • Native HLS has no API to pick a quality, so the quality menu disappears in Safari.
  • Two code paths with different events and error shapes.

DASH with Shaka Player or dash.js, HLS for Apple

Package both DASH and HLS (CMAF segments can be shared); DASH through an engine, HLS natively on Apple.

Pros

  • DASH has strong Widevine and PlayReady DRM support and rich manifests.

Cons

  • Two manifest formats to produce and test.
  • More moving parts for little gain if HLS covers our needs.

What we pick: HLS everywhere with a capability check. Only MP4 for short marketing clips. The component takes a list of sources with types, so a team can pass HLS and an MP4 fallback, and the loader picks the first one the browser can play. A product whose whole business is playback (see the YouTube guide) may run the engine on iPhone too, through ManagedMediaSource, for its own ABR rules and a quality menu; for a component shared by many teams, native HLS on Apple is less code and keeps AirPlay and FairPlay.

ts
async function attachSource(video: HTMLVideoElement, src: { url: string; type: string }) {
  const isHls = src.type === "application/vnd.apple.mpegurl" || src.url.endsWith(".m3u8");
  if (!isHls) { video.src = src.url; return () => video.removeAttribute("src"); }
  const nativeHls = video.canPlayType("application/vnd.apple.mpegurl") !== "";
  const hasMse = "MediaSource" in window || "ManagedMediaSource" in window;
  if (nativeHls && isAppleWebKit()) { video.src = src.url; return () => video.removeAttribute("src"); }
  if (!hasMse) throw new PlayerError("SRC_NOT_SUPPORTED");
  const { default: Hls } = await import("hls.js"); // loaded only now
  const hls = new Hls({ capLevelToPlayerSize: true, startLevel: -1 });
  hls.loadSource(src.url);
  hls.attachMedia(video);
  return () => hls.destroy();
}
Some non-Apple browsers now also report HLS support from canPlayType, so the native path is limited to Apple WebKit, where it brings AirPlay and FairPlay.
How a stream reaches the video. The CDN holds master.m3u8, 720p.m3u8, and segments like seg_041.m4s. In the browser, the source loader picks a path. On Apple WebKit it sets the video's src to the manifest natively. Elsewhere it loads hls.js, which fetches the manifest and segments, handles ABR and retries, and appends bytes to a MediaSource with SourceBuffers, which is the video's blob source. The video decodes and draws.CDNmaster.m3u8720p.m3u8seg_041.m4sBrowserSource loaderwhich path?videodecode, drawhls.jsABR, retriesMediaSourceSourceBuffernativeengineappendblob srcSafari: the video plays the manifest itself
Both paths end in the same video element, so the controls never know which one ran. Only the engine path exposes levels for a quality menu.

The quality menu

The menu lists Auto plus each level from the manifest (1080p, 720p, 480p, 360p). In Auto, show the level currently playing next to it, like Auto (720p), so a viewer can tell why it looks soft.

Picking a fixed level in hls.js has two flavors. Setting currentLevel switches at once by flushing the buffer (a short stall, a sharp picture now). Setting nextLevel switches from the next segment on without interrupting playback (no stall, a few seconds of the old quality). Use nextLevel for upgrades and currentLevel only when the user picks a lower level to stop stalls.

capLevelToPlayerSize keeps a 400 pixel wide embed from downloading 1080p. Remember the choice for the session only: a fixed quality saved forever turns into stalls on the next weak network.

Check your understanding · Exercise 8

Which statements about playing HLS in 2025-era browsers are true? Select all that apply.

Fullscreen, picture-in-picture, and phones

Fullscreen looks like one method call. The trap is which element you call it on. Call requestFullscreen() on the video, and the browser shows the video alone with its own native controls: our control bar, captions overlay, settings menu, and thumbnails stay behind on the page, invisible. Call it on the player's container, and everything we built comes along, scaled to the screen.

Two fullscreen targets side by side. Left, fullscreen on the video element: the screen shows only the video with the browser's own controls, and our captions and menus stay behind on the page. Right, fullscreen on the container: the screen shows the video with our captions and our control bar.fullscreen: videovideo onlybrowser controlsfullscreen: containerour captionsour control baroverlays left behindeverything comes along
Only the fullscreen element and its children are drawn. Call requestFullscreen() on the container so the controls and overlays come along; iPhone is the exception, with webkitEnterFullscreen() on the video.

Decision Which element goes fullscreen?

The video element

video.requestFullscreen().

Pros

  • Works the same as the native fullscreen button.

Cons

  • Our controls, captions overlay, and menus are not in the fullscreen element, so they disappear.
  • The browser's native controls appear, breaking the design.

The player container, with an iPhone fallback

container.requestFullscreen() where supported; listen for fullscreenchange to update the button and layout.

On iPhone, where elements other than video can't go fullscreen (iPad can), call video.webkitEnterFullscreen() and switch captions to native while there.

Pros

  • Same UI in fullscreen as on the page, everywhere except iPhone.
  • One button, one code path, plus a small fallback.

Cons

  • iPhone users get Apple's native player in fullscreen, without our thumbnails or menus.

What we pick: The container, with the iPhone fallback. Feature-detect, don't sniff: if document.fullscreenEnabled is false or the container lacks requestFullscreen, and the video has webkitEnterFullscreen, use that. The f key and a double click on the video toggle it; Escape exits by browser rule. Wrap requests in try and catch: they reject outside a user gesture.

In fullscreen, a few things change. Controls hide with the same inactivity rules but the cursor hides too. Captions grow because they're sized relative to the player. And on phones, lock to landscape where allowed with screen.orientation.lock('landscape'), which only works in fullscreen and only on some Android browsers, so treat failure as normal.

Picture-in-picture

Video picture-in-picture moves the video into a small always-on-top window so a learner can follow along in their code editor. video.requestPictureInPicture() works in Chromium browsers, Safari (including iPhone), and desktop Firefox since version 153 (mid 2026). Firefox on Android has no API, and older Firefox versions only offer their own PiP toggle on videos, so hide our button unless document.pictureInPictureEnabled is true. The window shows only the video, with the browser's own small controls.

Wire the Media Session API (navigator.mediaSession with metadata and action handlers for play, pause, seek back, seek forward) so those controls, the lock screen, and hardware media keys all drive our player.

Document Picture-in-Picture goes further: documentPictureInPicture.requestWindow() opens an always-on-top window that holds any HTML, so we can move the whole player, custom controls and captions included, into it and back. It's available on desktop in Chrome and Edge since version 116 and in Firefox since version 151 (2026), but not in Safari or on mobile. Treat it as an upgrade: if present, use it for the PiP button; if not, fall back to video PiP.

Phones: touch, gestures, and playsinline

  • No hover. Tapping the video shows the controls; tapping again hides them. It does not toggle play, because a tap that was meant to reveal controls must not pause the lesson. The center play button pauses.
  • Double tap to seek. Double tap the left third to go back 10 seconds and the right third to go forward, with a ripple and a -10 or +10 label. Wait about 300 ms after the first tap to know whether a second one comes, which is why the single-tap reaction is slightly delayed on touch only.
  • Big targets. At least 44 by 44 CSS pixels per control; a thicker seek bar while dragging; the hover preview appears above the finger during a drag, not under it.
  • playsinline. Without the playsinline attribute, iPhone Safari can force the video into native fullscreen when it plays. With it, the video plays inside the page and our controls stay in charge.
  • Data and battery. Default to preload=none on cellular for long videos, and cap quality to the player's size.

Check your understanding · Exercise 9

A team reports: in fullscreen, the custom captions and our settings menu vanish in desktop Chrome. Which causes are plausible? Select all that apply.

Architecture: the pieces and where the boundaries go

We now know the features. The architecture question is how to split them so that a team can theme the controls without touching streaming, swap hls.js for Shaka without touching the controls, and test the state machine without a browser. The split follows one rule: the media element is the source of truth, a controller turns its events into state, and everything else either reads that state or sends commands.

The video player's architecture. Inside the player root, a group of views: Controls UI for seek, buttons, and menus; Captions, as overlay or native; the Announcer live region; and Thumbnails, loading sprites on hover. Beside them: Plugins such as analytics and skip intro; the Controller with its reducer and store; the Source loader choosing native or hls.js; and the video element. The views send commands to the Controller and get state back. Plugins talk to the Controller too. The Controller asks the Source loader to load a source, which attaches to the video element, and the video's media events go back to the Controller. Outside, the Controller reads Preferences from localStorage, sends positions to the Progress API and batches to the QoE collector, and the Source loader fetches from the CDN.Player rootviewsControls UIseek bar, buttonsCaptionsoverlay or nativeAnnouncerlive regionThumbnailssprites on hoverPluginsanalytics, skip introControllerreducer + storeSource loadernative or hls.jsvideo elementcommandsstateload(src)eventsPreferenceslocalStorageProgress APIPUT positionQoE collectorCDNmanifest, VTT
The controller is the only part that touches the video element. Views and plugins read slices of its state and send commands, so each can be replaced without touching streaming.
PartResponsibilityWhy the boundary is here
Player rootThe container element: fullscreen target, keyboard scope, theme variables, context provider.Fullscreen and shortcuts must cover everything; one element owns both.
Media controllerListens to the video's events, runs the reducer, holds the store, exposes commands (play, seek, setRate).The only code that talks to the element. Controls never call video.play() themselves.
Source loaderPicks native or hls.js, attaches and destroys the engine, turns engine levels and errors into our own types.Engines differ; the rest of the player sees one shape.
Controls UIControl bar, seek bar, volume, settings menu, big play button, spinner, error overlay.Pure views of the store plus commands. Teams can replace any of them.
Captions rendererReads the chosen text track and draws cues with user styles.Separate so native and custom modes can swap.
Thumbnails serviceLoads the thumbnails VTT and sprites on first hover; answers which tile for time t.Lazy and cacheable, no effect on startup.
PreferencesVolume, mute, speed, caption language and style in localStorage.Shared by all players on the site; read once at mount.
Progress and analyticsSaves watch position; reports QoE events.Listens to the store, never blocks playback.
AnnouncerOne polite live region for shortcut feedback and state changes.One region per player avoids screen reader chatter.

Decision Where does player state live so that 4 updates a second don't re-render everything?

React state in the root, passed through context

The root holds state with useReducer and provides it through one context.

Pros

  • Idiomatic and small.

Cons

  • Every timeupdate re-renders every control that reads context, including menus and buttons that don't care about time.
  • Volume dragging re-renders the seek bar.

An external store with selectors

The controller owns a small store (subscribe, getState). Each control reads a slice with useSyncExternalStore and a selector, and re-renders only when its slice changes.

Pros

  • The play button re-renders on status change only; the time label 4 times a second; menus almost never.
  • Framework-agnostic: the same controller can back a web component build.

Cons

  • A little more setup than context.

Each control listens to the video directly

Every control adds its own event listeners to the element.

Pros

  • No central state.

Cons

  • No state machine, so the boolean bugs return.
  • No single place for analytics or plugins.

What we pick: An external store with selectors. For the smooth thumb, go one step further: between timeupdate events, a requestAnimationFrame loop reads video.currentTime and sets a CSS transform on the played bar, without React, while playing and visible. The store still updates about 4 times a second for the time label and ARIA.

How data moves

Follow one lesson from page load to the first progress save. The page renders the poster and a play button from server HTML. On play, the source loader fetches the manifest from the CDN, the engine fetches segments and appends them through MSE, and the video fires events that the controller reduces into state. Controls render from state.

Meanwhile, side channels run: captions and thumbnails come from the CDN as VTT and images, the progress service saves the position with a PUT every 10 seconds, and analytics batch QoE events to a collector.

Data flow for one lesson. Down the left: the CDN sends the manifest and 2 to 6 second segments to hls.js, which appends them through MSE to the video element, which sends media events to the Controller, which sends state slices to the Controls, and the Controls send commands back up. On the right: the CDN also serves VTT files and sprite JPEGs to captions and thumbnails; the Controller restores preferences from localStorage, sends PUT position every 10 seconds to the Progress API, and sends QoE batches every 30 seconds to the collector.CDNhls.jsvideo elementControllerControlsmanifest, segmentsappend via MSEmedia eventsstatecommandsCaptions, thumbslocalStorageProgress APIQoE collectorVTT, sprite JPEGspreferencesPUT position, 10 sQoE batch, 30 s
Bytes flow down the left; facts about playback flow out to the right. Nothing on the right can block playback: progress and analytics are fire and forget, sent with a keepalive fetch or sendBeacon on page hide.

Two boundaries deserve a defense in the interview. First, why a separate source loader? Because the engine is the part most likely to change: today hls.js, tomorrow Shaka for DASH and Widevine, and native HLS on Apple. If the controls ever import hls.js types, swapping engines means touching every control.

Second, why commands instead of letting controls touch the element? Because a command can be validated, logged, and measured in one place. A seek that came from the keyboard, a plugin, or a drag goes through the same seek(t), so analytics can tell user seeks from programmatic ones and the state machine sees every one.

The controller has no React in it. It's a plain class that takes a video element, attaches listeners, and exposes subscribe, getState, and commands. The React components are a thin layer on top. That makes the same core usable from a web component build for teams on other frameworks, and lets the reducer and controller be tested in Node with a fake element that fires scripted events, which is exactly how the state lab in chapter 4 works.

Check your understanding · Exercise 10

A team wants to add a Skip intro button that appears from 0:05 to 0:30. Which designs fit this architecture? Select all that apply.

Data model and the component API

The architecture chapter named the parts; this one pins down the data each part holds and the API teams see. The player holds little data, but it lives in five different places, and putting each piece in the right one prevents a class of bugs.

Media facts live in the element and are mirrored into the store. Preferences live in localStorage, watch progress on the server (learners switch devices), and thumbnail cues in an in-memory cache keyed by URL. Nothing about playback goes in the URL except an optional start time like ?t=95, for sharing a moment.

ts
type PlayerState = {
  status: Status;                 // the state machine
  resume: boolean;                // resume after seek
  time: number; duration: number; // seconds; duration NaN until metadata
  buffered: [start: number, end: number][];
  volume: number; muted: boolean; rate: number;
  levels: { id: number; height: number; bitrate: number }[];
  level: number | "auto"; playingLevel: number | null;
  textTracks: { id: string; lang: string; label: string; kind: "captions" | "subtitles" }[];
  captionLang: string | null;
  fullscreen: boolean; pip: boolean;
  error: PlayerError | null;      // { code, message, retryable }
};

type Preferences = {              // localStorage "player:prefs"
  volume: number; muted: boolean; rate: number;
  captionLang: string | null;
  captionStyle: {
    font: string; size: number; color: string; opacity: number;
    bg: string; bgOpacity: number; window: string;
    edge: "none" | "uniform" | "shadow" | "raised" | "depressed";
  };
};

type Progress = { videoId: string; position: number; duration: number; completed: boolean; updatedAt: string };
Store state, local preferences, and the server progress record.
Where player data lives. The video element holds the media truth and sends events to PlayerState in the store, which holds status, time, and level. Preferences in localStorage restore into the store at mount. The store saves Progress on the server every 10 seconds. Thumbnail cues sit in a memory cache keyed by URL. The URL holds only an optional start time like t=95, which seeds the store.video elementthe media truthPlayerStatestatus, time, levelPreferenceslocalStorageeventsrestoreProgressserver, per lessonThumbnail cuesmemory, by URLURL?t=95 onlysave, 10 sstart timeone home per fact; the store mirrors the element, never invents
Every fact has one home. The store mirrors the element and never invents media state; preferences, progress, and cues each live where their lifetime fits.

The component API

Decision How do teams control playback from outside?

Fully controlled props

playing and currentTime are props; the parent owns them and the player follows.

Pros

  • Familiar React pattern; easy to sync with other UI.

Cons

  • The element can refuse: if the parent says playing and autoplay is blocked, props and reality disagree.
  • currentTime as a prop re-renders the parent 4 times a second and turns every render into a possible seek.

Uncontrolled, with events and an imperative handle

Props set the starting point (src, startTime, autoPlay, muted).

Events report what happened (onPlay, onTimeUpdate, onEnded, onError, onQualityChange).

A ref exposes commands (play(), pause(), seek(t), setRate(r)) that return promises.

Pros

  • Matches how media really works: you ask, it reports.
  • No re-render storm in the parent.

Cons

  • Two ways in (props and the ref) to document.

What we pick: Uncontrolled with events and an imperative handle, like the native element. Commands return promises so callers can handle a blocked play. The component-api-design lesson covers when controlled props are worth it; for media they mostly aren't.

tsx
<VideoPlayer
  ref={playerRef}                          // play(), pause(), seek(t), setRate(r), enterFullscreen()
  sources={[
    { url: "https://cdn.example.com/l42/master.m3u8", type: "application/vnd.apple.mpegurl" },
    { url: "https://cdn.example.com/l42/720p.mp4", type: "video/mp4" },
  ]}
  poster="https://cdn.example.com/l42/poster.avif"
  tracks={[{ src: "/l42/en.vtt", kind: "captions", lang: "en", label: "English", default: true }]}
  thumbnails="/l42/thumbs.vtt"
  chapters="/l42/chapters.vtt"
  title="Lesson 42: Closures"             // names the player region for screen readers
  startTime={progress?.position}
  preload="metadata"
  shortcuts="focus"                        // or "page" on the main watch page
  plugins={[analyticsPlugin({ videoId: "l42" }), skipIntroPlugin()]}
  onEnded={() => router.push(nextLessonUrl)}
  onError={(e) => log(e.code)}
/>
What a course page writes. Theming uses CSS custom properties on the root, like --player-accent.

Events mirror the state machine, not raw media events. Teams get onPlay, onPause, onEnded, onSeek (with from and to times), onTimeUpdate (throttled to once a second, which is all a progress bar outside the player needs), onQualityChange, onCaptionChange, onFullscreenChange, and onError with our own error codes. Raw waiting and stalled are hidden behind onBufferingChange, so a consumer never has to rebuild the machine to know whether the user is staring at a spinner. Each event carries the player id, because pages with two players otherwise mix them up in logs.

Plugins get the store (read state, subscribe) and the command API, plus named slots to render into: controlBarEnd, overlay, settingsMenu. Analytics, skip intro, chapter markers, and later ads are all plugins, so the core stays small. Theming is CSS custom properties (--player-accent, --player-radius, --player-font) and slot replacement for teams that need a different button; never a prop per color. Progress uses two small endpoints.

http
GET /api/progress/l42
200 { "position": 95.2, "duration": 734, "completed": false, "updatedAt": "2026-03-02T10:04:11Z" }

PUT /api/progress/l42
{ "position": 105.4, "duration": 734, "updatedAt": "2026-03-02T10:04:21Z" }
204 No Content
The server keeps the newest updatedAt, so a stale write from a second tab can't move the learner back.

Check your understanding · Exercise 11

Which API choices will hurt teams that use the player? Select all that apply.

Resume position, quality metrics, and recovering from errors

Three features decide whether the player is trusted in production: it remembers where you were, it tells the team when viewers suffer, and it recovers from failures instead of showing a black box. All three hang off the state machine, which is the payoff for building it.

Saving and restoring watch position

Saving on every timeupdate would send 4 or more requests a second. Save instead every 10 seconds while playing, and right away on pause, on seeked, on ended (marking the lesson complete), and when the page is hidden (visibilitychange to hidden, or pagehide). For that last save, use fetch with keepalive: true, which survives the tab closing and still allows the PUT; navigator.sendBeacon also survives but can only POST.

Also write the position to localStorage, so a reload offline still resumes. On restore, wait for loadedmetadata, then seek to the saved position if it's more than 5 seconds in and more than 10 seconds before the end; otherwise start at 0. Show a small Resumed from 1:35, Start over chip for a few seconds, because jumping without telling people feels like a bug.

Two edge cases show up in production. A learner with the same lesson open in two tabs writes two positions; the server keeps the write with the newest updatedAt, so whichever tab played last wins and an idle tab can't move them backwards. A learner who finished a lesson and opens it again should start at 0, not 10 seconds before the end, so restore skips positions marked completed.

Mark a lesson complete at 90% watched or on ended, whichever comes first, because many people close the tab during the credits.

Quality of experience metrics

Video teams track a short list of metrics that predict whether people keep watching. Each is measured from state machine transitions, which is why the controller, not the UI, is where analytics plug in.

MetricHow the player measures itA healthy target
Startup timeFrom the play request to the first playing event.Under 1 s median, under 2.5 s at p95.
Exits before startSessions where the user asked to play but left before the first frame.Under 2%.
Rebuffer ratioTime in buffering divided by time in playing plus buffering. Seek time is excluded.Under 1%.
Rebuffer frequencyRebuffer events per minute of playback.Under 0.1 per minute.
Average bitrate and switchesTime-weighted bitrate of the levels played; count of level changes.Rising bitrate, few downward switches.
Playback failuresSessions that ended in error, split by code.Under 0.5% of starts.

Batch events in memory and send them every 30 seconds and on pagehide with sendBeacon. Tag each with a session id, video id, player version, browser, and the streaming path (native or hls.js), because the first question after a spike is always which browser. The analytics-tracking lesson covers batching and delivery in depth.

The first 30 seconds of a session on a time axis. Startup from 0 to 1.2 seconds, playing to 10, a seek wait from 10 to 10.8, playing to 20, a rebuffer from 20 to 23, and playing to 30. Startup time is 1.2 seconds; seek time 0.8 seconds is tracked separately; the rebuffer ratio is 3 divided by 25 plus 3, about 10.7 percent.startup 1.2 sseek 0.8 srebuffer 3 s0 s10 s20 s30 sstartup time = 1.2 sseek time 0.8 s, separaterebuffer ratio =3 / (25 + 3) = 10.7%
Each segment is time spent in one state of the machine, so the metrics come straight from transitions. A seek wait is not a stall, so it stays out of the rebuffer ratio.

Errors and recovery

When the element fails, video.error.code says why: 1 MEDIA_ERR_ABORTED (the user or code stopped loading), 2 MEDIA_ERR_NETWORK, 3 MEDIA_ERR_DECODE, 4 MEDIA_ERR_SRC_NOT_SUPPORTED. Engines report richer errors (hls.js marks each as network or media, fatal or not). The source loader maps both into our own PlayerError with a code, a retryable flag, and a message for people.

FailureRecoveryWhat the user sees
A segment request fails (5xx, timeout)The engine retries with backoff; if fatal, restart loading at the current time, up to 3 times.A spinner, then playback continues. Error only after retries.
Manifest 403 (signed URL expired)Ask the app for a fresh URL through an onSourceExpired callback, reload at the same time.A short spinner.
Decode errorIn hls.js, call recoverMediaError() once; if it fails again, drop to a lower level or the MP4 fallback.A brief stall, maybe lower quality.
Source not supportedTry the next source in the list.Nothing, if a fallback exists; else a clear message.
DRM: key system unavailablerequestMediaKeySystemAccess rejects. No retry.This browser can't play protected video, with a link to supported browsers.
DRM: license denied or output restrictedLicense server 403, or a key status of output-restricted (for example an external display without HDCP).Specific text: sign in again, or disconnect the external display.
OfflinePause retries until the online event, then resume.You're offline. Playback will continue when you reconnect.

Retries need limits and spacing. Wait 1, 2, then 4 seconds with a little random jitter, so ten thousand viewers of a popular lesson don't hit the CDN in lockstep after a blip. Keep showing the spinner while retrying, and switch to the error overlay only after the last attempt, with a Retry button that restarts at the current time. Log every retry, even successful ones: a rise in retries is the early warning before failures climb.

Sequence diagram of pressing play, across User, Controls, Controller, Media (the video element and hls.js), and CDN. The user clicks play. Controls call play on the Controller, which sets status loading and the controls re-render with a pause icon and a delayed spinner. The Controller loads the engine and the source. The engine requests the master.m3u8 manifest and the CDN replies 503. The engine retries after 1 second and gets 200 with the manifest. It requests the first segment and gets 200 with 600 KB. The video fires playing to the Controller, which sets status playing and the controls hide the spinner.UserControlsControllerMediaCDNclick playplay()loadingload(src)GET manifest503retry in 1 s200 manifestGET seg 1200, 600 KBplayingstatus playing
The UI changes twice: to loading at once, and to playing only when frames move. If all 3 retries had failed, the controller would move to error and show a Retry button instead.

Check your understanding · Exercise 12

A viewer presses play, waits 1.2 s for the first frame, watches 10 minutes, seeks once (0.8 s of seek wait), and stalls twice for 3 s each. Which numbers are right? Select all that apply.

Performance and accessibility, end to end

A player is often the largest thing on the page, so it can ruin Largest Contentful Paint (LCP) and steal bandwidth from everything else. On a course page with a player above the fold, the poster is usually the LCP element. On a marketing page with five video cards, eager players would download five manifests and start five decoders before the user picks one.

Decision When do we load the player code and the media?

Everything eagerly

Render the full player with the page and preload=auto.

Pros

  • Fastest start when the user clicks.

Cons

  • Player JavaScript (30 KB) and engine (well over 100 KB) compete with the page's own code.
  • preload=auto may download megabytes of video nobody watches.

A facade until interaction

Server HTML renders the poster image and a real play button, sized with aspect-ratio.

On click (or keyboard activation), load the player code, mount it, and call play() inside the same gesture chain.

Pros

  • Near zero cost for players nobody presses: ideal for lists of videos.
  • The poster paints first, so LCP is an image, not a script.

Cons

  • The first press waits for code and manifest: add 200 to 500 ms.
  • Calling play() after an async import can lose the user gesture in some browsers, so start muted or prefetch the code on hover and focus.

Mount early, fetch media late

Load the player shell when the poster nears the viewport (IntersectionObserver with a 300 px margin) or on idle.

Use preload=metadata on desktop, preload=none on cellular or for long lists; load the engine on first play.

Pros

  • Controls are ready instantly; duration shows before playing.
  • Media bytes only for the video the user picks.

Cons

  • Some cost for players that are never pressed.

What we pick: Mount early and fetch media late for the main player on a lesson page; a facade for lists and marketing pages. In both, the poster is a real image with explicit dimensions and fetchpriority=high when above the fold, in AVIF or WebP, and we add a preconnect to the video CDN. The loading-strategies lesson covers facades and idle loading.

preload is a hint, browsers may ignore it, and the default differs between browsers, so always set it explicitly. With an MSE engine, the engine decides what to fetch, not preload.
preload valueWhat the browser fetchesUse it for
noneNothing until play. Duration unknown.Lists, cellular, long videos below the fold.
metadataHeaders and duration, maybe a first frame: tens of KB.The main player on desktop.
autoAs much as the browser likes, possibly the whole file.Rarely; a short hero clip the user will almost surely watch.

Runtime cost matters too. Keep timeupdate work tiny, move the played bar with a CSS transform from a requestAnimationFrame loop that stops when paused or off screen, and pause offscreen autoplaying previews with IntersectionObserver to free the decoder. Destroy the engine and remove the src when the player unmounts, or phones leak decoders and memory across route changes.

Accessibility checklist, with the reasons

  • A named region. The container has role=region and aria-label like Video player: Lesson 42, so screen reader users can jump to it.
  • Buttons with state. Either the label changes (Play becomes Pause, Mute becomes Unmute) or the label stays and aria-pressed changes (Captions, pressed). Never both, or it reads Pause, pressed.
  • Sliders that speak time. Seek and volume follow the slider pattern with aria-valuetext in words.
  • Menus that behave like menus. The settings menu follows the menu button pattern with menuitemradio and returns focus on close.
  • Visible focus on video. A focus ring that shows on both bright and dark frames: a two-color outline, plus a gradient scrim behind the control bar for icon contrast of at least 3 to 1.
  • Announcements, not noise. One polite live region for shortcut results and errors. Don't announce every time update.
  • Captions on by default for users who chose them once, with the styling menu from the captions chapter.
  • Reduced motion. With prefers-reduced-motion, skip the double-tap ripple and bubble animations, and never autoplay with motion for those users.
  • Autoplay with a way out. Video that starts by itself and runs more than 5 seconds needs a visible way to pause it (WCAG 2.2.2), and sound that starts by itself for more than 3 seconds needs pause or its own volume control (WCAG 1.4.2). Autoplaying previews start muted and keep the pause button reachable.
  • No traps. Tab moves through controls in visual order and out of the player; fullscreen can be left with Escape and with a button.

Check your understanding · Exercise 13

A marketing page shows 12 video cards in a grid. Which choices are sound? Select all that apply.

Running it in the interview

A video player question rewards depth over breadth. Interviewers have heard the list of controls many times; what they listen for is whether you know the media element reports truth through events, whether you'd build a state machine, and whether keyboard and screen reader users are in your design from the first minute rather than at the end. Here's a 45 minute plan.

MinutesWhat you doWhat you say out loud
0 to 5Ask scope questions: who embeds it, on-demand or live, devices, accessibility bar, DRM.I'll assume on-demand HLS, many teams embedding, WCAG AA, iPhone as a big share.
5 to 10Draw the wireframe: viewport, overlays, seek bar, control row, settings menu.Custom controls over a native video element, and the container is the fullscreen target.
10 to 18Draw the state machine and list the events that drive it.The UI shows what the element reports, not what the user asked for. play() returns a promise.
18 to 26Architecture: controller, store, source loader, controls, captions, services.One controller talks to the element; controls read slices of state and send commands.
26 to 36Deep dive on the two topics the interviewer picks: usually the seek bar and streaming, or captions and keyboard.Buffered ranges, pointer capture, sprite thumbnails; or hls.js versus native HLS.
36 to 42Component API, analytics, errors.Uncontrolled with events and a ref; QoE from transitions; retries before errors.
42 to 45Summarize trade-offs and what you'd do next.Live streaming and ads would be my next two plugins.

Draw the state machine early. It's the single diagram that shows senior thinking here, and every later topic (spinner timing, analytics, resume, errors) refers back to it. When you mention a browser difference, name the browser and the API: iPhone Safari and webkitEnterFullscreen, Firefox on Android and its missing picture-in-picture API. Specifics beat it depends.

Think first

Follow-up: how does the design change for a live stream?

Think first

Follow-up: how would you add pre-roll ads without breaking the player?

Think first

Follow-up: the same component goes into an autoplaying feed of short videos. What breaks?

Check your understanding · Exercise 14

Which moves earn senior-level signal in this interview? Select all that apply.

Summary

  • Build your own controls over a native video element: the browser decodes, syncs, and protects content; you own everything the user touches.
  • The media element is the source of truth. play() returns a promise that can reject, and the UI follows events (playing, waiting, seeked), not clicks.
  • Model the player as a state machine (idle, loading, paused, playing, buffering, seeking, ended, error) with a resume flag for seeks, and delay the spinner by about 300 ms.
  • The seek bar is a custom slider with layers: every buffered range, pointer capture for drags, sprite thumbnails from a WebVTT file, and aria-valuetext in words.
  • Scope single-key shortcuts to the focused player, skip text fields and focused buttons, and announce results in a polite live region.
  • Render captions yourself for styling and layout, but switch to native captions wherever your DOM can't be seen: iPhone fullscreen and video picture-in-picture.
  • Play HLS natively on Apple WebKit and through hls.js with MSE or ManagedMediaSource elsewhere, behind one sources prop with fallbacks.
  • Fullscreen the container, not the video, with webkitEnterFullscreen as the iPhone fallback.
  • Measure startup time, rebuffer ratio, bitrate, and failures from state transitions; save progress every 10 seconds and on page hide.
  • Expose an uncontrolled API: props for the start, events for what happened, a ref with promise-returning commands, plugins with slots, and CSS variables for themes.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next