Hey folks, Rahul here ๐
YouTube, Netflix, Disney+ โ video players look like a solved problem until you try building one. Custom controls, adaptive bitrate streaming, buffering strategies, Picture-in-Picture, keyboard shortcuts, and accessibility for deaf/blind users. The <video> tag gets you 10% of the way there.
R โ Requirements
Functional Requirements
- Play/pause, seek, volume, fullscreen controls
- Adaptive Bitrate Streaming (ABR) โ auto quality switching
- Manual quality selection (1080p, 720p, 480p, auto)
- Captions/subtitles with multiple tracks
- Playback speed control (0.5x โ 2x)
- Picture-in-Picture (PiP) mode
- Keyboard shortcuts (Space, F, M, โ/โ, โ/โ)
- Thumbnail preview on seek bar hover
- Resume from last position ("Continue watching")
Non-Functional Requirements
- Startup time: First frame within 2 seconds
- Buffering: Minimize rebuffering events to <1% of playback time
- Accessibility: Full keyboard control + screen reader announcements
- Mobile: Touch gestures (double-tap seek, swipe volume)
- Analytics: Buffer ratio, quality switches, engagement heatmap
A โ Architecture
Streaming Protocols
| Protocol | How It Works | Use When |
|---|---|---|
| HLS (HTTP Live Streaming) | Video split into .ts segments + .m3u8 manifest | Default choice. Safari native, rest via hls.js |
| DASH (MPEG-DASH) | Similar segmented approach + .mpd manifest | DRM required (Widevine), more flexible |
| Progressive MP4 | Single file, byte-range requests | Short videos (<5min), simple hosting |
Recommendation: HLS with hls.js. It covers 99% of use cases, works everywhere, and has excellent adaptive bitrate support.
Component Architecture
VideoPlayer
โโโ VideoElement // <video> tag with HLS source
โโโ ControlsOverlay // Auto-hide controls
โ โโโ ProgressBar // Seek bar + buffer indicator + thumbnails
โ โ โโโ ThumbnailPreview // Sprite sheet thumbnails on hover
โ โโโ PlayPauseButton
โ โโโ VolumeControl // Slider + mute toggle
โ โโโ TimeDisplay // 1:23 / 5:00
โ โโโ QualitySelector // Auto, 1080p, 720p, 480p
โ โโโ CaptionSelector // Subtitle tracks
โ โโโ SpeedSelector // 0.5x โ 2x
โ โโโ PiPButton
โ โโโ FullscreenButton
โโโ BufferingSpinner // Shown during rebuffering
โโโ OverlayActions // Big play button, error state
โโโ KeyboardHandler // Global shortcuts when focusedHLS.js Integration
import Hls from 'hls.js';
function useHlsPlayer(videoRef: RefObject<HTMLVideoElement>, src: string) {
const hlsRef = useRef<Hls | null>(null);
const [levels, setLevels] = useState<QualityLevel[]>([]);
const [currentLevel, setCurrentLevel] = useState(-1); // -1 = auto
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Safari supports HLS natively
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
return;
}
if (!Hls.isSupported()) return;
const hls = new Hls({
startLevel: -1, // Auto quality
capLevelToPlayerSize: true, // Don't load 4K for a 360px player
maxBufferLength: 30, // Buffer 30s ahead
maxMaxBufferLength: 60,
lowLatencyMode: false,
// ABR tuning
abrEwmaDefaultEstimate: 500000, // Initial bandwidth estimate (500kbps)
abrBandWidthUpFactor: 0.7, // Conservative upswitch
abrBandWidthFactor: 0.95, // Aggressive downswitch (avoid rebuffer)
});
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
setLevels(data.levels.map((l, i) => ({
index: i,
height: l.height,
bitrate: l.bitrate,
label: `${l.height}p`,
})));
});
hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
setCurrentLevel(data.level);
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad(); // Retry
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
hlsRef.current = hls;
return () => hls.destroy();
}, [src]);
const setQuality = (levelIndex: number) => {
if (hlsRef.current) {
hlsRef.current.currentLevel = levelIndex; // -1 for auto
}
};
return { levels, currentLevel, setQuality };
}D โ Data Model
Player State
interface PlayerState {
// Playback
status: 'idle' | 'loading' | 'playing' | 'paused' | 'buffering' | 'ended' | 'error';
currentTime: number;
duration: number;
playbackRate: number;
// Buffer
bufferedRanges: TimeRange[]; // [{start: 0, end: 30}, ...]
bufferHealth: number; // Seconds buffered ahead
// Volume
volume: number; // 0-1
isMuted: boolean;
// Quality
currentQuality: QualityLevel | 'auto';
availableQualities: QualityLevel[];
bandwidth: number; // Estimated bandwidth (bps)
// Captions
activeCaptionTrack: string | null;
availableCaptionTracks: CaptionTrack[];
// UI
isFullscreen: boolean;
isPiP: boolean;
controlsVisible: boolean;
isHoveringProgress: boolean;
hoverTime: number | null; // Seek preview time
// Error
error: { code: number; message: string } | null;
}
interface QualityLevel {
index: number;
height: number; // 1080, 720, 480
bitrate: number;
label: string;
}
interface CaptionTrack {
id: string;
language: string;
label: string; // "English", "Spanish"
kind: 'subtitles' | 'captions';
}I โ Interface Definition
Progress Bar with Buffer Visualization
function ProgressBar({ state, onSeek }: ProgressBarProps) {
const barRef = useRef<HTMLDivElement>(null);
const [hoverPos, setHoverPos] = useState<number | null>(null);
const progressPercent = (state.currentTime / state.duration) * 100;
const handleClick = (e: React.MouseEvent) => {
const rect = barRef.current!.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(percent * state.duration);
};
const handleHover = (e: React.MouseEvent) => {
const rect = barRef.current!.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
setHoverPos(percent);
};
return (
<div
ref={barRef}
className="relative h-1 hover:h-2 transition-all cursor-pointer group"
onClick={handleClick}
onMouseMove={handleHover}
onMouseLeave={() => setHoverPos(null)}
role="slider"
aria-label="Video progress"
aria-valuenow={Math.round(state.currentTime)}
aria-valuemax={Math.round(state.duration)}
aria-valuetext={formatTime(state.currentTime)}
>
{/* Background */}
<div className="absolute inset-0 bg-muted/40 rounded-full" />
{/* Buffered ranges */}
{state.bufferedRanges.map((range, i) => (
<div
key={i}
className="absolute h-full bg-muted-foreground/30 rounded-full"
style={{
left: `${(range.start / state.duration) * 100}%`,
width: `${((range.end - range.start) / state.duration) * 100}%`,
}}
/>
))}
{/* Progress */}
<div
className="absolute h-full bg-primary rounded-full"
style={{ width: `${progressPercent}%` }}
/>
{/* Scrub handle */}
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-primary rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `${progressPercent}%`, transform: 'translate(-50%, -50%)' }}
/>
{/* Thumbnail preview on hover */}
{hoverPos !== null && (
<ThumbnailPreview
time={hoverPos * state.duration}
position={hoverPos}
/>
)}
</div>
);
}Keyboard Shortcuts
function usePlayerKeyboard(
video: HTMLVideoElement | null,
dispatch: (action: PlayerAction) => void
) {
useEffect(() => {
if (!video) return;
const handler = (e: KeyboardEvent) => {
// Only handle when player is focused
if (!video.closest('[data-player]')?.contains(document.activeElement)) return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
video.paused ? video.play() : video.pause();
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
dispatch({ type: 'TOGGLE_MUTE' });
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime -= e.shiftKey ? 10 : 5;
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime += e.shiftKey ? 10 : 5;
break;
case 'ArrowUp':
e.preventDefault();
dispatch({ type: 'SET_VOLUME', volume: Math.min(1, video.volume + 0.1) });
break;
case 'ArrowDown':
e.preventDefault();
dispatch({ type: 'SET_VOLUME', volume: Math.max(0, video.volume - 0.1) });
break;
case 'c':
dispatch({ type: 'TOGGLE_CAPTIONS' });
break;
case '>':
video.playbackRate = Math.min(2, video.playbackRate + 0.25);
break;
case '<':
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25);
break;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [video]);
}O โ Optimizations
1. Controls Auto-Hide
function useControlsVisibility() {
const [visible, setVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const showControls = useCallback(() => {
setVisible(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setVisible(false), 3000);
}, []);
const hideControls = useCallback(() => {
clearTimeout(timerRef.current);
setVisible(false);
}, []);
// Keep visible while paused
useEffect(() => {
if (isPaused) {
setVisible(true);
clearTimeout(timerRef.current);
} else {
showControls();
}
}, [isPaused]);
return { visible, showControls, hideControls };
}2. Thumbnail Sprite Sheet Preview
// Server generates a sprite sheet: 10x10 grid of thumbnails
// Each thumbnail = 160x90px, one per 10 seconds of video
interface ThumbnailConfig {
url: string; // Sprite sheet URL
width: 160;
height: 90;
columns: 10;
interval: 10; // One thumbnail per 10 seconds
}
function ThumbnailPreview({ time, config }: { time: number; config: ThumbnailConfig }) {
const index = Math.floor(time / config.interval);
const col = index % config.columns;
const row = Math.floor(index / config.columns);
return (
<div
className="absolute bottom-8 -translate-x-1/2 border-2 border-background rounded shadow-lg"
style={{
width: config.width,
height: config.height,
backgroundImage: `url(${config.url})`,
backgroundPosition: `-${col * config.width}px -${row * config.height}px`,
backgroundSize: `${config.columns * config.width}px auto`,
}}
>
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-xs bg-background/80 px-1 rounded">
{formatTime(time)}
</span>
</div>
);
}3. Resume Playback Position
function useResumePosition(videoId: string, video: HTMLVideoElement | null) {
const STORAGE_KEY = `video-progress-${videoId}`;
// Restore on mount
useEffect(() => {
if (!video) return;
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const time = parseFloat(saved);
// Don't resume if within last 30s (probably finished)
if (time > 5 && video.duration - time > 30) {
video.currentTime = time;
}
}
}, [video]);
// Save periodically
useEffect(() => {
if (!video) return;
const interval = setInterval(() => {
if (!video.paused) {
localStorage.setItem(STORAGE_KEY, String(video.currentTime));
}
}, 5000);
return () => clearInterval(interval);
}, [video]);
// Clear on complete
useEffect(() => {
if (!video) return;
const handleEnded = () => localStorage.removeItem(STORAGE_KEY);
video.addEventListener('ended', handleEnded);
return () => video.removeEventListener('ended', handleEnded);
}, [video]);
}4. Engagement Analytics
interface VideoAnalytics {
videoId: string;
totalWatchTime: number;
percentWatched: number;
qualitySwitches: number;
rebufferCount: number;
rebufferDuration: number;
startupTime: number; // Time to first frame
heatmap: number[]; // Per-second watch count (shows rewatched sections)
}
function useVideoAnalytics(videoId: string, video: HTMLVideoElement | null) {
const heatmap = useRef<number[]>([]);
useEffect(() => {
if (!video) return;
const trackSecond = () => {
const second = Math.floor(video.currentTime);
if (!heatmap.current[second]) heatmap.current[second] = 0;
heatmap.current[second]++;
};
const interval = setInterval(trackSecond, 1000);
// Send on unload
const flush = () => {
navigator.sendBeacon('/api/analytics/video', JSON.stringify({
videoId,
heatmap: heatmap.current,
percentWatched: video.currentTime / video.duration,
}));
};
window.addEventListener('beforeunload', flush);
return () => {
clearInterval(interval);
window.removeEventListener('beforeunload', flush);
flush();
};
}, [video, videoId]);
}Production Gotchas Rahul Has Debugged ๐ฅ
- Autoplay Policies: Modern browsers block autoplay with sound. Always start muted for autoplay, then unmute on user interaction. Check
video.play().catch()for theNotAllowedError. - iOS Fullscreen: iOS Safari forces native fullscreen controls for
<video>. Addplaysinlineattribute to use custom controls inline. PiP also requires user gesture to activate. - requestAnimationFrame for Time Updates: Don't rely on the
timeupdateevent โ it fires ~4x/second. UserequestAnimationFramefor smooth progress bar updates at 60fps. - Seek While Buffering: If the user seeks to an unbuffered position, HLS.js needs to flush the buffer and refetch. Show a loading spinner specifically for seek operations, not just for initial load.
- Memory Leaks: HLS.js accumulates source buffers. Call
hls.destroy()on unmount. Also revoke anyURL.createObjectURLreferences used for blob sources.
Next: #14: Design an Image Carousel / Gallery โ touch gestures, preloading strategies, virtualization for 1000+ images, and lightbox UX. ๐ผ๏ธ