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.
| Question | Answer we assume | What 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:
kor Space,jandl, 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.
- 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. - Time is continuous, the UI is not. The playhead moves 30 to 60 frames a second, but
timeupdatefires 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. - 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.
- 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.
- Browsers differ in ways that matter. iPhone Safari won't make a
divfullscreen, and it gets Media Source Extensions only through a newerManagedMediaSourceAPI. 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.
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?
They see a pause icon over a frozen frame for 4 seconds, with no sign of loading. Worse, if play() rejects because of autoplay rules, the icon stays wrong for good. The UI is showing the user's intent, not what the media element is doing.
The fix is to treat the click as a request and let events drive the UI. The button follows the element's paused flag (the play and pause events), a spinner follows waiting and playing, and a rejected promise puts the button back. The next chapters build exactly that.
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?
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.
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 });
}
}
}
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?
| Value | readyState meaning | What the UI does |
|---|---|---|
0 HAVE_NOTHING | Nothing known yet. | Show the poster and a play button. |
1 HAVE_METADATA | Duration and size known, no frame yet. | Show total time, enable the seek bar. |
2 HAVE_CURRENT_DATA | The frame at the playhead, but not the next one. | A frame is visible; playback would stall at once. |
3 HAVE_FUTURE_DATA | A little ahead of the playhead. | Can start, may stall soon. |
4 HAVE_ENOUGH_DATA | The 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.
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.| Event | When it fires | What the player does with it |
|---|---|---|
loadstart | The browser began loading a new source. | Reset time, buffered ranges, and errors from the previous source. |
loadedmetadata | Duration and dimensions are known (readyState 1). | Show duration, size the seek bar, restore a saved position. |
loadeddata | The frame at the playhead is available (readyState 2). | A first frame can replace the poster if you want one. |
canplay | Enough data to start (readyState 3 or more). | End the loading state. |
canplaythrough | The 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. |
play | paused became false (a request, not a frame yet). | Flip the button to pause. |
playing | Frames are actually moving, also after a stall. | Hide the spinner. |
waiting | Playback stopped because the next frame isn't loaded. | Show the spinner after a short delay; start a rebuffer timer. |
timeupdate | The 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). |
progress | More bytes arrived. | Redraw the buffered ranges. |
seeking and seeked | A seek started and finished. | Show and hide the seek spinner; count seek time. |
ended | The playhead reached the end. | Show replay or the next video. |
error | Loading or decoding failed; video.error.code says why. | Show an error with a retry action. |
volumechange, ratechange | Volume, 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?
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
| State | Meaning | Main button | Overlay |
|---|---|---|---|
idle | Poster shown, nothing loaded (with preload=none). | Play | Big play button on the poster. |
loading | User asked to play; metadata or first frames are on the way. | Pause | Spinner after 300 ms. |
paused | Ready and stopped. | Play | None, or the big play button if never started. |
playing | Frames are moving. | Pause | None. |
buffering | Was playing, ran out of data (waiting). | Pause | Spinner after 300 ms. |
seeking | A seek is in progress; remembers whether to resume. | Play or pause, as before the seek | Spinner after 300 ms; seek bar shows the target. |
ended | Reached the end. | Replay | Replay or next-video card. |
error | Failed after retries. | Retry | Message with a retry button. |
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.
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;
}
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.
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?
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.
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;
}
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.
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?
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.
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
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.
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.
The settings menu
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?
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.
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.
| Key | Action | Note |
|---|---|---|
Space or k | Play or pause | Space on a focused button already clicks it; don't toggle twice. |
j and l | Back and forward 10 seconds | Shows a -10 or +10 bubble over the video. |
| Left and Right | Back and forward 5 seconds | When the seek bar has focus, its own slider keys apply. |
| Up and Down | Volume up and down 5% | When the seek bar or an open menu has focus, their own keys apply instead. |
f | Toggle fullscreen | Fullscreen goes on the player container, not the video. |
m | Toggle mute | Keeps the volume level for unmuting. |
c | Toggle captions | Turns on the last used language. |
0 to 9 | Jump to 0% to 90% of the video | 5 jumps to the middle. |
< and > (Shift with comma or period) | Slower or faster playback | Steps through the speed list, for example 1x to 1.25x. |
, and . while paused | Step back or forward one frame | Approximate: about 1/30 second. |
| Escape | Close a menu | The browser itself exits fullscreen on Escape. |
Decision Where do shortcuts listen?
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).
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"
}
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?
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.
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;
}
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?
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.
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();
}
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.
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?
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
playsinlineattribute, 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=noneon 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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Player root | The container element: fullscreen target, keyboard scope, theme variables, context provider. | Fullscreen and shortcuts must cover everything; one element owns both. |
| Media controller | Listens 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 loader | Picks 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 UI | Control 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 renderer | Reads the chosen text track and draws cues with user styles. | Separate so native and custom modes can swap. |
| Thumbnails service | Loads the thumbnails VTT and sprites on first hover; answers which tile for time t. | Lazy and cacheable, no effect on startup. |
| Preferences | Volume, mute, speed, caption language and style in localStorage. | Shared by all players on the site; read once at mount. |
| Progress and analytics | Saves watch position; reports QoE events. | Listens to the store, never blocks playback. |
| Announcer | One 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?
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.
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.
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 };
The component API
Decision How do teams control playback from outside?
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.
<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)}
/>
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.
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
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.
| Metric | How the player measures it | A healthy target |
|---|---|---|
| Startup time | From the play request to the first playing event. | Under 1 s median, under 2.5 s at p95. |
| Exits before start | Sessions where the user asked to play but left before the first frame. | Under 2%. |
| Rebuffer ratio | Time in buffering divided by time in playing plus buffering. Seek time is excluded. | Under 1%. |
| Rebuffer frequency | Rebuffer events per minute of playback. | Under 0.1 per minute. |
| Average bitrate and switches | Time-weighted bitrate of the levels played; count of level changes. | Rising bitrate, few downward switches. |
| Playback failures | Sessions 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.
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.
| Failure | Recovery | What 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 error | In 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 supported | Try the next source in the list. | Nothing, if a fallback exists; else a clear message. |
| DRM: key system unavailable | requestMediaKeySystemAccess rejects. No retry. | This browser can't play protected video, with a link to supported browsers. |
| DRM: license denied or output restricted | License 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. |
| Offline | Pause 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.
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?
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 value | What the browser fetches | Use it for |
|---|---|---|
none | Nothing until play. Duration unknown. | Lists, cellular, long videos below the fold. |
metadata | Headers and duration, maybe a first frame: tens of KB. | The main player on desktop. |
auto | As 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=regionandaria-labellike 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-pressedchanges (Captions, pressed). Never both, or it reads Pause, pressed. - Sliders that speak time. Seek and volume follow the slider pattern with
aria-valuetextin words. - Menus that behave like menus. The settings menu follows the menu button pattern with
menuitemradioand 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.
| Minutes | What you do | What you say out loud |
|---|---|---|
| 0 to 5 | Ask 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 10 | Draw 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 18 | Draw 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 26 | Architecture: controller, store, source loader, controls, captions, services. | One controller talks to the element; controls read slices of state and send commands. |
| 26 to 36 | Deep 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 42 | Component API, analytics, errors. | Uncontrolled with events and a ref; QoE from transitions; retries before errors. |
| 42 to 45 | Summarize 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?
The duration is unknown or infinite and the seekable window moves. The seek bar shows video.seekable (for example the last 2 hours), not 0 to duration, and a Live button jumps to the live edge and shows whether you're behind.
The state machine gains a behind-live flag; startup aims for the live edge minus a target latency; the engine runs in low-latency mode (LL-HLS) if latency matters. Resume position no longer makes sense; DVR window does.
Think first
Follow-up: how would you add pre-roll ads without breaking the player?
As a plugin that takes over the viewport: the ad SDK (for example the IMA SDK with VAST) usually plays in its own video element layered on top, while our controls hide or switch to an ad mode with a skip countdown. The main video's state machine waits in idle until the plugin reports the ad finished or failed.
Analytics must separate ad time from content time, and the keyboard must still work: a skip button that's reachable by Tab, and pause that pauses the ad.
Think first
Follow-up: the same component goes into an autoplaying feed of short videos. What breaks?
Autoplay rules: the player must start muted and offer an Unmute button. Decoder limits: only the visible player should hold a decoder, so the feed pauses and unloads offscreen players. Startup: preload the next video's manifest and first segment.
The TikTok feed guide covers this pattern; the component needs a preload control and an unload() command to support it.
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
videoelement: 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-valuetextin 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
webkitEnterFullscreenas 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.
References
- MDN: HTMLMediaElement Every property, method, and event the player builds on.
- MDN: TimeRanges The shape of buffered and seekable.
- MDN: Web Video Text Tracks (WebVTT) Cue syntax, settings, and the textTracks API.
- MDN: Media Source Extensions API How streaming engines feed the video element.
- MDN: ManagedMediaSource The MSE variant that brings engines to iPhone.
- hls.js on GitHub The HLS engine used here: API, levels, and error recovery.
- Chrome for Developers: Autoplay policy in Chrome Why play() rejects and how muted autoplay works.
- Chrome for Developers: Picture-in-Picture for any element Document Picture-in-Picture for custom controls.
- MDN: Picture-in-Picture API Video picture-in-picture: requestPictureInPicture and pictureInPictureEnabled.
- WAI-ARIA APG: Slider pattern Keys and attributes for the seek and volume sliders.
- WCAG 2.2: Understanding Captions (Prerecorded) What success criterion 1.2.2 requires of captions.
- WCAG 2.2: Understanding Character Key Shortcuts Why single-letter shortcuts are scoped to focus.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.