DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #13: Design a Video Player
XLinkedInReddit
MediumFrontend Engineering

System Design #13: Design a Video Player

D
DevPrep Team
7 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Streaming Protocols
  • Component Architecture
  • HLS.js Integration
  • D โ€” Data Model
  • Player State
  • I โ€” Interface Definition
  • Progress Bar with Buffer Visualization
  • Keyboard Shortcuts
  • O โ€” Optimizations
  • 1. Controls Auto-Hide
  • 2. Thumbnail Sprite Sheet Preview
  • 3. Resume Playback Position
  • 4. Engagement Analytics
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

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

ProtocolHow It WorksUse When
HLS (HTTP Live Streaming)Video split into .ts segments + .m3u8 manifestDefault choice. Safari native, rest via hls.js
DASH (MPEG-DASH)Similar segmented approach + .mpd manifestDRM required (Widevine), more flexible
Progressive MP4Single file, byte-range requestsShort 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 focused

HLS.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 ๐Ÿ”ฅ

  1. Autoplay Policies: Modern browsers block autoplay with sound. Always start muted for autoplay, then unmute on user interaction. Check video.play().catch() for the NotAllowedError.
  2. iOS Fullscreen: iOS Safari forces native fullscreen controls for <video>. Add playsinline attribute to use custom controls inline. PiP also requires user gesture to activate.
  3. requestAnimationFrame for Time Updates: Don't rely on the timeupdate event โ€” it fires ~4x/second. Use requestAnimationFrame for smooth progress bar updates at 60fps.
  4. 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.
  5. Memory Leaks: HLS.js accumulates source buffers. Call hls.destroy() on unmount. Also revoke any URL.createObjectURL references used for blob sources.

Next: #14: Design an Image Carousel / Gallery โ€” touch gestures, preloading strategies, virtualization for 1000+ images, and lightbox UX. ๐Ÿ–ผ๏ธ

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Streaming Protocols
  • Component Architecture
  • HLS.js Integration
  • D โ€” Data Model
  • Player State
  • I โ€” Interface Definition
  • Progress Bar with Buffer Visualization
  • Keyboard Shortcuts
  • O โ€” Optimizations
  • 1. Controls Auto-Hide
  • 2. Thumbnail Sprite Sheet Preview
  • 3. Resume Playback Position
  • 4. Engagement Analytics
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.