Back to blog

Build a Video Platform: Video Player & Progress Tracking

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Video Player & Progress Tracking

Streams are secured, Nginx is serving HLS segments, signed URLs are working. But right now, the only way to watch a video is to paste a URL into ffplay. Users need a real player — one that switches quality automatically, remembers where they left off, and lets them control playback speed.

In this post, we'll build a custom React video player using hls.js. Not a wrapper around someone else's player — a player we control completely, with adaptive streaming, progress tracking, and a clean UI built with our own components.

Time commitment: 3–4 hours
Prerequisites: Phase 7: Secure Video Streaming

What we'll build in this post:
✅ hls.js integration with React for adaptive HLS playback
✅ Quality selector (auto, 360p, 720p)
✅ Playback speed control (0.5x to 2x)
✅ Custom player controls (play/pause, seek bar, volume, fullscreen)
✅ Resume from last position on page load
✅ Debounced progress saves to the backend
sendBeacon for saving progress on page unload
✅ Backend API for lesson progress tracking


Architecture Overview


Setting Up hls.js

Installation

cd web
npm install hls.js

hls.js is a JavaScript library that plays HLS streams in any browser with MSE (Media Source Extensions) support. Safari has native HLS support, so hls.js gracefully falls back to the native player on Safari.

The HLS Hook

Let's create a custom React hook that manages hls.js lifecycle:

// web/src/hooks/useHls.ts
"use client";
 
import { useEffect, useRef, useState, useCallback } from "react";
import Hls, { Level } from "hls.js";
 
interface HlsConfig {
  token: string;
  expires: number;
}
 
interface QualityLevel {
  index: number;
  height: number;
  width: number;
  bitrate: number;
  name: string;
}
 
interface UseHlsReturn {
  videoRef: React.RefObject<HTMLVideoElement | null>;
  isReady: boolean;
  error: string | null;
  qualityLevels: QualityLevel[];
  currentQuality: number; // -1 = auto
  setQuality: (index: number) => void;
  isAutoQuality: boolean;
}
 
export function useHls(src: string | null, config: HlsConfig | null): UseHlsReturn {
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const hlsRef = useRef<Hls | null>(null);
  const [isReady, setIsReady] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [qualityLevels, setQualityLevels] = useState<QualityLevel[]>([]);
  const [currentQuality, setCurrentQuality] = useState(-1); // -1 = auto
 
  const setQuality = useCallback((index: number) => {
    if (!hlsRef.current) return;
 
    // -1 = auto (ABR decides)
    hlsRef.current.currentLevel = index;
    setCurrentQuality(index);
  }, []);
 
  useEffect(() => {
    if (!src || !config || !videoRef.current) return;
 
    const video = videoRef.current;
 
    // Safari has native HLS support
    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src;
      setIsReady(true);
      return;
    }
 
    if (!Hls.isSupported()) {
      setError("HLS is not supported in this browser");
      return;
    }
 
    const hls = new Hls({
      xhrSetup: (xhr: XMLHttpRequest, url: string) => {
        // Append signed token to every HLS request
        const separator = url.includes("?") ? "&" : "?";
        const signedUrl = `${url}${separator}token=${config.token}&expires=${config.expires}`;
        xhr.open("GET", signedUrl, true);
      },
      // Start with the lowest quality for fast initial load
      startLevel: 0,
      // Enable ABR (Adaptive Bitrate)
      abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
    });
 
    hlsRef.current = hls;
 
    hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
      setIsReady(true);
 
      // Extract quality levels
      const levels: QualityLevel[] = data.levels.map(
        (level: Level, index: number) => ({
          index,
          height: level.height,
          width: level.width,
          bitrate: level.bitrate,
          name: `${level.height}p`,
        })
      );
      setQualityLevels(levels);
    });
 
    hls.on(Hls.Events.ERROR, (_event, data) => {
      if (data.fatal) {
        switch (data.type) {
          case Hls.ErrorTypes.NETWORK_ERROR:
            // Try to recover from network errors
            hls.startLoad();
            break;
          case Hls.ErrorTypes.MEDIA_ERROR:
            hls.recoverMediaError();
            break;
          default:
            setError(`Fatal playback error: ${data.details}`);
            hls.destroy();
            break;
        }
      }
    });
 
    hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
      // Update current quality when ABR switches
      if (hls.autoLevelEnabled) {
        setCurrentQuality(-1);
      }
    });
 
    hls.loadSource(src);
    hls.attachMedia(video);
 
    return () => {
      hls.destroy();
      hlsRef.current = null;
    };
  }, [src, config]);
 
  return {
    videoRef,
    isReady,
    error,
    qualityLevels,
    currentQuality,
    setQuality,
    isAutoQuality: currentQuality === -1,
  };
}

Key design decisions:

  • Token injection via xhrSetup: Every XHR request hls.js makes gets the signed token appended. This covers master playlist, quality playlists, and all segment requests.
  • Safari fallback: Safari supports HLS natively through the <video> tag, so we skip hls.js entirely.
  • Error recovery: Network errors retry automatically. Media errors attempt recovery. Only truly fatal errors stop playback.
  • ABR: Adaptive bitrate starts at 500kbps estimate and adjusts based on actual bandwidth.

Custom Video Player Component

Player UI

// web/src/components/player/VideoPlayer.tsx
"use client";
 
import { useState, useEffect, useRef, useCallback } from "react";
import { useHls } from "@/hooks/useHls";
import { PlayerControls } from "./PlayerControls";
import { QualitySelector } from "./QualitySelector";
import { SpeedSelector } from "./SpeedSelector";
import { Loader2 } from "lucide-react";
 
interface VideoPlayerProps {
  playlistUrl: string;
  token: string;
  expires: number;
  initialPosition?: number;
  duration: number;
  onProgress?: (seconds: number) => void;
  onComplete?: () => void;
}
 
export function VideoPlayer({
  playlistUrl,
  token,
  expires,
  initialPosition = 0,
  duration,
  onProgress,
  onComplete,
}: VideoPlayerProps) {
  const { videoRef, isReady, error, qualityLevels, currentQuality, setQuality, isAutoQuality } =
    useHls(playlistUrl, { token, expires });
 
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [buffered, setBuffered] = useState(0);
  const [volume, setVolume] = useState(1);
  const [isMuted, setIsMuted] = useState(false);
  const [playbackRate, setPlaybackRate] = useState(1);
  const [isFullscreen, setIsFullscreen] = useState(false);
  const [showControls, setShowControls] = useState(true);
  const [isBuffering, setIsBuffering] = useState(false);
 
  const containerRef = useRef<HTMLDivElement>(null);
  const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const hasResumedRef = useRef(false);
 
  // Resume from last position
  useEffect(() => {
    if (!isReady || !videoRef.current || hasResumedRef.current) return;
    if (initialPosition > 0) {
      videoRef.current.currentTime = initialPosition;
    }
    hasResumedRef.current = true;
  }, [isReady, initialPosition, videoRef]);
 
  // Track video time updates
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
 
    const handleTimeUpdate = () => {
      setCurrentTime(video.currentTime);
 
      // Update buffered range
      if (video.buffered.length > 0) {
        setBuffered(video.buffered.end(video.buffered.length - 1));
      }
    };
 
    const handlePlay = () => setIsPlaying(true);
    const handlePause = () => setIsPlaying(false);
    const handleWaiting = () => setIsBuffering(true);
    const handlePlaying = () => setIsBuffering(false);
 
    const handleEnded = () => {
      setIsPlaying(false);
      onComplete?.();
    };
 
    video.addEventListener("timeupdate", handleTimeUpdate);
    video.addEventListener("play", handlePlay);
    video.addEventListener("pause", handlePause);
    video.addEventListener("waiting", handleWaiting);
    video.addEventListener("playing", handlePlaying);
    video.addEventListener("ended", handleEnded);
 
    return () => {
      video.removeEventListener("timeupdate", handleTimeUpdate);
      video.removeEventListener("play", handlePlay);
      video.removeEventListener("pause", handlePause);
      video.removeEventListener("waiting", handleWaiting);
      video.removeEventListener("playing", handlePlaying);
      video.removeEventListener("ended", handleEnded);
    };
  }, [videoRef, onComplete]);
 
  // Auto-hide controls
  const resetControlsTimeout = useCallback(() => {
    setShowControls(true);
    if (controlsTimeoutRef.current) {
      clearTimeout(controlsTimeoutRef.current);
    }
    if (isPlaying) {
      controlsTimeoutRef.current = setTimeout(() => {
        setShowControls(false);
      }, 3000);
    }
  }, [isPlaying]);
 
  // Player actions
  const togglePlay = useCallback(() => {
    const video = videoRef.current;
    if (!video) return;
    if (video.paused) {
      video.play();
    } else {
      video.pause();
    }
  }, [videoRef]);
 
  const seek = useCallback(
    (time: number) => {
      const video = videoRef.current;
      if (!video) return;
      video.currentTime = Math.max(0, Math.min(time, duration));
    },
    [videoRef, duration]
  );
 
  const changeVolume = useCallback(
    (newVolume: number) => {
      const video = videoRef.current;
      if (!video) return;
      video.volume = newVolume;
      setVolume(newVolume);
      setIsMuted(newVolume === 0);
    },
    [videoRef]
  );
 
  const toggleMute = useCallback(() => {
    const video = videoRef.current;
    if (!video) return;
    video.muted = !video.muted;
    setIsMuted(video.muted);
  }, [videoRef]);
 
  const changePlaybackRate = useCallback(
    (rate: number) => {
      const video = videoRef.current;
      if (!video) return;
      video.playbackRate = rate;
      setPlaybackRate(rate);
    },
    [videoRef]
  );
 
  const toggleFullscreen = useCallback(() => {
    const container = containerRef.current;
    if (!container) return;
 
    if (document.fullscreenElement) {
      document.exitFullscreen();
      setIsFullscreen(false);
    } else {
      container.requestFullscreen();
      setIsFullscreen(true);
    }
  }, []);
 
  // Keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Only handle when player is focused
      if (!containerRef.current?.contains(document.activeElement) &&
          document.activeElement !== document.body) return;
 
      switch (e.key) {
        case " ":
        case "k":
          e.preventDefault();
          togglePlay();
          break;
        case "ArrowLeft":
          e.preventDefault();
          seek(currentTime - 10);
          break;
        case "ArrowRight":
          e.preventDefault();
          seek(currentTime + 10);
          break;
        case "ArrowUp":
          e.preventDefault();
          changeVolume(Math.min(1, volume + 0.1));
          break;
        case "ArrowDown":
          e.preventDefault();
          changeVolume(Math.max(0, volume - 0.1));
          break;
        case "f":
          e.preventDefault();
          toggleFullscreen();
          break;
        case "m":
          e.preventDefault();
          toggleMute();
          break;
      }
    };
 
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [togglePlay, seek, currentTime, changeVolume, volume, toggleFullscreen, toggleMute]);
 
  if (error) {
    return (
      <div className="aspect-video bg-black flex items-center justify-center text-white">
        <p className="text-red-400">Playback error: {error}</p>
      </div>
    );
  }
 
  return (
    <div
      ref={containerRef}
      className="relative aspect-video bg-black group"
      onMouseMove={resetControlsTimeout}
      onMouseLeave={() => isPlaying && setShowControls(false)}
    >
      <video
        ref={videoRef}
        className="w-full h-full"
        onClick={togglePlay}
        playsInline
      />
 
      {/* Buffering spinner */}
      {isBuffering && (
        <div className="absolute inset-0 flex items-center justify-center">
          <Loader2 className="h-12 w-12 animate-spin text-white" />
        </div>
      )}
 
      {/* Loading state */}
      {!isReady && !error && (
        <div className="absolute inset-0 flex items-center justify-center">
          <Loader2 className="h-12 w-12 animate-spin text-white/60" />
        </div>
      )}
 
      {/* Controls overlay */}
      <div
        className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80
          to-transparent p-4 transition-opacity duration-300
          ${showControls ? "opacity-100" : "opacity-0 pointer-events-none"}`}
      >
        <PlayerControls
          isPlaying={isPlaying}
          currentTime={currentTime}
          duration={duration}
          buffered={buffered}
          volume={volume}
          isMuted={isMuted}
          isFullscreen={isFullscreen}
          onTogglePlay={togglePlay}
          onSeek={seek}
          onVolumeChange={changeVolume}
          onToggleMute={toggleMute}
          onToggleFullscreen={toggleFullscreen}
        />
 
        <div className="flex items-center justify-end gap-2 mt-2">
          <SpeedSelector
            currentRate={playbackRate}
            onRateChange={changePlaybackRate}
          />
          <QualitySelector
            levels={qualityLevels}
            currentLevel={currentQuality}
            isAuto={isAutoQuality}
            onLevelChange={setQuality}
          />
        </div>
      </div>
    </div>
  );
}

Player Controls

// web/src/components/player/PlayerControls.tsx
"use client";
 
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from "lucide-react";
 
interface PlayerControlsProps {
  isPlaying: boolean;
  currentTime: number;
  duration: number;
  buffered: number;
  volume: number;
  isMuted: boolean;
  isFullscreen: boolean;
  onTogglePlay: () => void;
  onSeek: (time: number) => void;
  onVolumeChange: (volume: number) => void;
  onToggleMute: () => void;
  onToggleFullscreen: () => void;
}
 
function formatTime(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
 
  if (h > 0) {
    return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
  }
  return `${m}:${s.toString().padStart(2, "0")}`;
}
 
export function PlayerControls({
  isPlaying,
  currentTime,
  duration,
  buffered,
  volume,
  isMuted,
  isFullscreen,
  onTogglePlay,
  onSeek,
  onVolumeChange,
  onToggleMute,
  onToggleFullscreen,
}: PlayerControlsProps) {
  const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
  const bufferedProgress = duration > 0 ? (buffered / duration) * 100 : 0;
 
  const handleSeekBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const percent = (e.clientX - rect.left) / rect.width;
    onSeek(percent * duration);
  };
 
  return (
    <div className="space-y-2">
      {/* Seek bar */}
      <div
        className="relative h-1 bg-white/20 rounded-full cursor-pointer group/seek hover:h-1.5 transition-all"
        onClick={handleSeekBarClick}
      >
        {/* Buffered */}
        <div
          className="absolute top-0 left-0 h-full bg-white/30 rounded-full"
          style={{ width: `${bufferedProgress}%` }}
        />
        {/* Progress */}
        <div
          className="absolute top-0 left-0 h-full bg-primary rounded-full"
          style={{ width: `${progress}%` }}
        />
        {/* Thumb */}
        <div
          className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-primary rounded-full
            opacity-0 group-hover/seek:opacity-100 transition-opacity"
          style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }}
        />
      </div>
 
      {/* Controls row */}
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          {/* Play/Pause */}
          <button
            onClick={onTogglePlay}
            className="text-white hover:text-primary transition-colors"
          >
            {isPlaying ? (
              <Pause className="h-5 w-5" fill="currentColor" />
            ) : (
              <Play className="h-5 w-5" fill="currentColor" />
            )}
          </button>
 
          {/* Volume */}
          <div className="flex items-center gap-1 group/volume">
            <button
              onClick={onToggleMute}
              className="text-white hover:text-primary transition-colors"
            >
              {isMuted || volume === 0 ? (
                <VolumeX className="h-5 w-5" />
              ) : (
                <Volume2 className="h-5 w-5" />
              )}
            </button>
            <input
              type="range"
              min={0}
              max={1}
              step={0.05}
              value={isMuted ? 0 : volume}
              onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
              className="w-0 group-hover/volume:w-20 transition-all duration-200
                accent-primary cursor-pointer"
            />
          </div>
 
          {/* Time display */}
          <span className="text-white/80 text-sm font-mono">
            {formatTime(currentTime)} / {formatTime(duration)}
          </span>
        </div>
 
        <div className="flex items-center gap-2">
          {/* Fullscreen */}
          <button
            onClick={onToggleFullscreen}
            className="text-white hover:text-primary transition-colors"
          >
            {isFullscreen ? (
              <Minimize className="h-5 w-5" />
            ) : (
              <Maximize className="h-5 w-5" />
            )}
          </button>
        </div>
      </div>
    </div>
  );
}

Quality Selector

// web/src/components/player/QualitySelector.tsx
"use client";
 
import { useState, useRef, useEffect } from "react";
import { Settings } from "lucide-react";
 
interface QualityLevel {
  index: number;
  height: number;
  name: string;
}
 
interface QualitySelectorProps {
  levels: QualityLevel[];
  currentLevel: number;
  isAuto: boolean;
  onLevelChange: (index: number) => void;
}
 
export function QualitySelector({
  levels,
  currentLevel,
  isAuto,
  onLevelChange,
}: QualitySelectorProps) {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
 
  // Close menu on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);
 
  if (levels.length === 0) return null;
 
  const currentLabel = isAuto
    ? `Auto`
    : levels.find((l) => l.index === currentLevel)?.name || "Auto";
 
  return (
    <div className="relative" ref={menuRef}>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="flex items-center gap-1 text-white/80 hover:text-white text-sm
          transition-colors px-2 py-1 rounded"
      >
        <Settings className="h-4 w-4" />
        <span>{currentLabel}</span>
      </button>
 
      {isOpen && (
        <div className="absolute bottom-full right-0 mb-2 bg-black/90 rounded-lg
          border border-white/10 overflow-hidden min-w-[120px]">
          {/* Auto option */}
          <button
            onClick={() => {
              onLevelChange(-1);
              setIsOpen(false);
            }}
            className={`w-full text-left px-3 py-2 text-sm hover:bg-white/10
              transition-colors ${isAuto ? "text-primary" : "text-white/80"}`}
          >
            Auto
          </button>
 
          {/* Quality levels (highest first) */}
          {[...levels].reverse().map((level) => (
            <button
              key={level.index}
              onClick={() => {
                onLevelChange(level.index);
                setIsOpen(false);
              }}
              className={`w-full text-left px-3 py-2 text-sm hover:bg-white/10
                transition-colors ${
                  currentLevel === level.index && !isAuto
                    ? "text-primary"
                    : "text-white/80"
                }`}
            >
              {level.name}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Speed Selector

// web/src/components/player/SpeedSelector.tsx
"use client";
 
import { useState, useRef, useEffect } from "react";
 
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
 
interface SpeedSelectorProps {
  currentRate: number;
  onRateChange: (rate: number) => void;
}
 
export function SpeedSelector({ currentRate, onRateChange }: SpeedSelectorProps) {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);
 
  return (
    <div className="relative" ref={menuRef}>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="text-white/80 hover:text-white text-sm transition-colors
          px-2 py-1 rounded"
      >
        {currentRate}x
      </button>
 
      {isOpen && (
        <div className="absolute bottom-full right-0 mb-2 bg-black/90 rounded-lg
          border border-white/10 overflow-hidden min-w-[80px]">
          {SPEED_OPTIONS.map((speed) => (
            <button
              key={speed}
              onClick={() => {
                onRateChange(speed);
                setIsOpen(false);
              }}
              className={`w-full text-left px-3 py-2 text-sm hover:bg-white/10
                transition-colors ${
                  currentRate === speed ? "text-primary" : "text-white/80"
                }`}
            >
              {speed}x
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Progress Tracking

The Problem

Users expect to resume where they left off. If someone watches 15 minutes of a 30-minute lesson, closes the tab, and comes back tomorrow — the player should start at 15:00, not 0:00.

We also need to track completion for the course progress dashboard (how much of a course has a user finished?).

Backend: Progress API

Flyway Migration

-- src/main/resources/db/migration/V4__create_lesson_progress.sql
 
CREATE TABLE lesson_progress (
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    lesson_id   BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
    watched_seconds INT NOT NULL DEFAULT 0,
    completed   BOOLEAN NOT NULL DEFAULT FALSE,
    last_position INT NOT NULL DEFAULT 0,
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW(),
 
    PRIMARY KEY (user_id, lesson_id)
);
 
CREATE TABLE course_enrollments (
    user_id       BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    course_id     BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
    progress_pct  SMALLINT NOT NULL DEFAULT 0,
    last_lesson_id BIGINT REFERENCES lessons(id),
    enrolled_at   TIMESTAMP NOT NULL DEFAULT NOW(),
    completed_at  TIMESTAMP,
 
    PRIMARY KEY (user_id, course_id)
);
 
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id);
CREATE INDEX idx_course_enrollments_user ON course_enrollments(user_id);

Entity

// src/main/java/com/videoplatform/api/entity/LessonProgress.java
package com.videoplatform.api.entity;
 
import jakarta.persistence.*;
import java.time.LocalDateTime;
 
@Entity
@Table(name = "lesson_progress")
@IdClass(LessonProgressId.class)
public class LessonProgress {
 
    @Id
    @Column(name = "user_id")
    private Long userId;
 
    @Id
    @Column(name = "lesson_id")
    private Long lessonId;
 
    @Column(name = "watched_seconds", nullable = false)
    private int watchedSeconds = 0;
 
    @Column(nullable = false)
    private boolean completed = false;
 
    @Column(name = "last_position", nullable = false)
    private int lastPosition = 0;
 
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt = LocalDateTime.now();
 
    // Getters and setters
    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
 
    public Long getLessonId() { return lessonId; }
    public void setLessonId(Long lessonId) { this.lessonId = lessonId; }
 
    public int getWatchedSeconds() { return watchedSeconds; }
    public void setWatchedSeconds(int watchedSeconds) { this.watchedSeconds = watchedSeconds; }
 
    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed = completed; }
 
    public int getLastPosition() { return lastPosition; }
    public void setLastPosition(int lastPosition) { this.lastPosition = lastPosition; }
 
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
// src/main/java/com/videoplatform/api/entity/LessonProgressId.java
package com.videoplatform.api.entity;
 
import java.io.Serializable;
import java.util.Objects;
 
public class LessonProgressId implements Serializable {
    private Long userId;
    private Long lessonId;
 
    public LessonProgressId() {}
 
    public LessonProgressId(Long userId, Long lessonId) {
        this.userId = userId;
        this.lessonId = lessonId;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof LessonProgressId that)) return false;
        return Objects.equals(userId, that.userId) && Objects.equals(lessonId, that.lessonId);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(userId, lessonId);
    }
}

Repository

// src/main/java/com/videoplatform/api/repository/LessonProgressRepository.java
package com.videoplatform.api.repository;
 
import com.videoplatform.api.entity.LessonProgress;
import com.videoplatform.api.entity.LessonProgressId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
 
import java.util.List;
import java.util.Optional;
 
public interface LessonProgressRepository extends JpaRepository<LessonProgress, LessonProgressId> {
 
    Optional<LessonProgress> findByUserIdAndLessonId(Long userId, Long lessonId);
 
    List<LessonProgress> findByUserIdAndLessonIdIn(Long userId, List<Long> lessonIds);
 
    @Query("SELECT COUNT(lp) FROM LessonProgress lp WHERE lp.userId = :userId " +
           "AND lp.lessonId IN :lessonIds AND lp.completed = true")
    long countCompletedByUserIdAndLessonIds(Long userId, List<Long> lessonIds);
}

Service

// src/main/java/com/videoplatform/api/service/ProgressService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.entity.LessonProgress;
import com.videoplatform.api.entity.LessonProgressId;
import com.videoplatform.api.repository.LessonProgressRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.LocalDateTime;
import java.util.Optional;
 
@Service
public class ProgressService {
 
    private static final double COMPLETION_THRESHOLD = 0.9; // 90% watched = completed
 
    private final LessonProgressRepository progressRepository;
 
    public ProgressService(LessonProgressRepository progressRepository) {
        this.progressRepository = progressRepository;
    }
 
    public Optional<LessonProgress> getProgress(Long userId, Long lessonId) {
        return progressRepository.findByUserIdAndLessonId(userId, lessonId);
    }
 
    @Transactional
    public LessonProgress updateProgress(Long userId, Long lessonId,
                                          int watchedSeconds, int lastPosition,
                                          int lessonDuration) {
        LessonProgress progress = progressRepository
                .findByUserIdAndLessonId(userId, lessonId)
                .orElseGet(() -> {
                    LessonProgress p = new LessonProgress();
                    p.setUserId(userId);
                    p.setLessonId(lessonId);
                    return p;
                });
 
        // Only update if the new position is actually later
        // (prevents rewinding from reducing watched seconds)
        progress.setWatchedSeconds(Math.max(progress.getWatchedSeconds(), watchedSeconds));
        progress.setLastPosition(lastPosition);
        progress.setUpdatedAt(LocalDateTime.now());
 
        // Mark as completed if watched >= 90% of the lesson
        if (!progress.isCompleted() && lessonDuration > 0) {
            double watchedRatio = (double) progress.getWatchedSeconds() / lessonDuration;
            if (watchedRatio >= COMPLETION_THRESHOLD) {
                progress.setCompleted(true);
            }
        }
 
        return progressRepository.save(progress);
    }
}

Controller

// src/main/java/com/videoplatform/api/controller/ProgressController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.request.ProgressUpdateRequest;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.ProgressResponse;
import com.videoplatform.api.entity.Lesson;
import com.videoplatform.api.entity.LessonProgress;
import com.videoplatform.api.entity.User;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.repository.UserRepository;
import com.videoplatform.api.service.ProgressService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/progress")
public class ProgressController {
 
    private final ProgressService progressService;
    private final UserRepository userRepository;
    private final LessonRepository lessonRepository;
 
    public ProgressController(
            ProgressService progressService,
            UserRepository userRepository,
            LessonRepository lessonRepository) {
        this.progressService = progressService;
        this.userRepository = userRepository;
        this.lessonRepository = lessonRepository;
    }
 
    @GetMapping("/{lessonId}")
    public ResponseEntity<ApiResponse<ProgressResponse>> getProgress(
            @PathVariable Long lessonId,
            @AuthenticationPrincipal UserDetails userDetails) {
 
        User user = findUser(userDetails);
 
        ProgressResponse response = progressService.getProgress(user.getId(), lessonId)
                .map(p -> new ProgressResponse(
                        p.getWatchedSeconds(),
                        p.getLastPosition(),
                        p.isCompleted()
                ))
                .orElse(new ProgressResponse(0, 0, false));
 
        return ResponseEntity.ok(ApiResponse.success(response));
    }
 
    @PutMapping("/{lessonId}")
    public ResponseEntity<ApiResponse<ProgressResponse>> updateProgress(
            @PathVariable Long lessonId,
            @RequestBody ProgressUpdateRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {
 
        User user = findUser(userDetails);
 
        Lesson lesson = lessonRepository.findById(lessonId)
                .orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
 
        LessonProgress progress = progressService.updateProgress(
                user.getId(),
                lessonId,
                request.watchedSeconds(),
                request.lastPosition(),
                lesson.getDuration()
        );
 
        ProgressResponse response = new ProgressResponse(
                progress.getWatchedSeconds(),
                progress.getLastPosition(),
                progress.isCompleted()
        );
 
        return ResponseEntity.ok(ApiResponse.success(response));
    }
 
    private User findUser(UserDetails userDetails) {
        return userRepository.findByEmail(userDetails.getUsername())
                .orElseThrow(() -> new ResourceNotFoundException("User not found"));
    }
}

DTOs

// src/main/java/com/videoplatform/api/dto/request/ProgressUpdateRequest.java
package com.videoplatform.api.dto.request;
 
public record ProgressUpdateRequest(
    int watchedSeconds,
    int lastPosition
) {}
// src/main/java/com/videoplatform/api/dto/response/ProgressResponse.java
package com.videoplatform.api.dto.response;
 
public record ProgressResponse(
    int watchedSeconds,
    int lastPosition,
    boolean completed
) {}

Frontend: Debounced Progress Saving

Saving progress on every timeupdate event (which fires ~4 times per second) would flood the API with requests. Instead, we debounce: save at most once every 10 seconds, and always save when the user leaves the page.

Progress Hook

// web/src/hooks/useProgressTracking.ts
"use client";
 
import { useEffect, useRef, useCallback } from "react";
 
interface UseProgressTrackingOptions {
  lessonId: number;
  currentTime: number;
  duration: number;
  isPlaying: boolean;
  enabled: boolean;
}
 
const SAVE_INTERVAL_MS = 10_000; // Save every 10 seconds
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
export function useProgressTracking({
  lessonId,
  currentTime,
  duration,
  isPlaying,
  enabled,
}: UseProgressTrackingOptions) {
  const lastSavedRef = useRef<number>(0);
  const currentTimeRef = useRef<number>(currentTime);
  const maxWatchedRef = useRef<number>(0);
 
  // Keep refs in sync
  currentTimeRef.current = currentTime;
  maxWatchedRef.current = Math.max(maxWatchedRef.current, Math.floor(currentTime));
 
  const saveProgress = useCallback(
    async (position: number, watched: number) => {
      if (!enabled) return;
 
      try {
        const token = localStorage.getItem("accessToken");
        await fetch(`${API_BASE}/api/progress/${lessonId}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({
            watchedSeconds: watched,
            lastPosition: position,
          }),
        });
      } catch (error) {
        // Silently fail — we'll save again soon
        console.error("Failed to save progress:", error);
      }
    },
    [lessonId, enabled]
  );
 
  // Periodic save while playing
  useEffect(() => {
    if (!isPlaying || !enabled) return;
 
    const interval = setInterval(() => {
      const now = Date.now();
      if (now - lastSavedRef.current >= SAVE_INTERVAL_MS) {
        lastSavedRef.current = now;
        saveProgress(
          Math.floor(currentTimeRef.current),
          maxWatchedRef.current
        );
      }
    }, SAVE_INTERVAL_MS);
 
    return () => clearInterval(interval);
  }, [isPlaying, enabled, saveProgress]);
 
  // Save on pause
  useEffect(() => {
    if (!isPlaying && enabled && currentTimeRef.current > 0) {
      saveProgress(
        Math.floor(currentTimeRef.current),
        maxWatchedRef.current
      );
    }
  }, [isPlaying, enabled, saveProgress]);
 
  // Save on page unload using sendBeacon
  useEffect(() => {
    if (!enabled) return;
 
    const handleBeforeUnload = () => {
      const position = Math.floor(currentTimeRef.current);
      const watched = maxWatchedRef.current;
 
      if (position === 0) return;
 
      const token = localStorage.getItem("accessToken");
 
      // sendBeacon is guaranteed to fire even when the page is closing
      const data = JSON.stringify({
        watchedSeconds: watched,
        lastPosition: position,
      });
 
      const blob = new Blob([data], { type: "application/json" });
 
      // sendBeacon doesn't support custom headers, so we use a special endpoint
      navigator.sendBeacon(
        `${API_BASE}/api/progress/${lessonId}/beacon?token=${token}`,
        blob
      );
    };
 
    window.addEventListener("beforeunload", handleBeforeUnload);
    // Also save on visibility change (mobile tab switching)
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        handleBeforeUnload();
      }
    });
 
    return () => {
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, [lessonId, enabled]);
}

Beacon Endpoint

sendBeacon can't set custom HTTP headers like Authorization. We need a special endpoint that accepts the token as a query parameter:

// Add to ProgressController.java
 
@PostMapping("/{lessonId}/beacon")
public ResponseEntity<Void> beaconProgress(
        @PathVariable Long lessonId,
        @RequestParam("token") String token,
        @RequestBody ProgressUpdateRequest request) {
 
    // Validate JWT token manually (not through Spring Security filter)
    String email = jwtService.extractUsername(token);
    if (email == null || !jwtService.isTokenValid(token)) {
        return ResponseEntity.status(401).build();
    }
 
    User user = userRepository.findByEmail(email)
            .orElse(null);
    if (user == null) {
        return ResponseEntity.status(401).build();
    }
 
    Lesson lesson = lessonRepository.findById(lessonId).orElse(null);
    if (lesson == null) {
        return ResponseEntity.notFound().build();
    }
 
    progressService.updateProgress(
            user.getId(),
            lessonId,
            request.watchedSeconds(),
            request.lastPosition(),
            lesson.getDuration()
    );
 
    return ResponseEntity.ok().build();
}

Why sendBeacon?

Regular fetch requests are cancelled when the page unloads — the browser kills pending XHRs when you close a tab or navigate away. sendBeacon is designed specifically for this use case: it queues the request and guarantees delivery even after the page is gone.

Limitations:

  • Can't set custom headers (hence the token in the query string)
  • Payload size limit (~64KB, plenty for our use case)
  • POST only (no PUT)

Putting It All Together: Lesson Page

Lesson Page Component

// web/src/app/(app)/lessons/[id]/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { VideoPlayer } from "@/components/player/VideoPlayer";
import { useProgressTracking } from "@/hooks/useProgressTracking";
import { Loader2 } from "lucide-react";
 
interface StreamInfo {
  playlistUrl: string;
  token: string;
  expiresAt: number;
  durationSeconds: number;
}
 
interface ProgressInfo {
  watchedSeconds: number;
  lastPosition: number;
  completed: boolean;
}
 
interface LessonInfo {
  id: number;
  title: string;
  description: string;
  duration: number;
}
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
export default function LessonPage() {
  const params = useParams();
  const lessonId = Number(params.id);
 
  const [lesson, setLesson] = useState<LessonInfo | null>(null);
  const [streamInfo, setStreamInfo] = useState<StreamInfo | null>(null);
  const [progress, setProgress] = useState<ProgressInfo | null>(null);
  const [currentTime, setCurrentTime] = useState(0);
  const [isPlaying, setIsPlaying] = useState(false);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  // Fetch stream info and progress on mount
  useEffect(() => {
    async function loadLesson() {
      try {
        const token = localStorage.getItem("accessToken");
        const headers = { Authorization: `Bearer ${token}` };
 
        // Fetch stream URL and progress in parallel
        const [streamRes, progressRes] = await Promise.all([
          fetch(`${API_BASE}/api/lessons/${lessonId}/stream`, { headers }),
          fetch(`${API_BASE}/api/progress/${lessonId}`, { headers }),
        ]);
 
        if (!streamRes.ok) {
          const data = await streamRes.json();
          throw new Error(data.message || "Failed to load video");
        }
 
        const streamData = await streamRes.json();
        setStreamInfo(streamData.data);
 
        if (progressRes.ok) {
          const progressData = await progressRes.json();
          setProgress(progressData.data);
        }
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to load lesson");
      } finally {
        setLoading(false);
      }
    }
 
    loadLesson();
  }, [lessonId]);
 
  // Progress tracking
  useProgressTracking({
    lessonId,
    currentTime,
    duration: streamInfo?.durationSeconds || 0,
    isPlaying,
    enabled: !!streamInfo,
  });
 
  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <Loader2 className="h-8 w-8 animate-spin" />
      </div>
    );
  }
 
  if (error) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <p className="text-red-500">{error}</p>
      </div>
    );
  }
 
  if (!streamInfo) return null;
 
  return (
    <div className="max-w-5xl mx-auto">
      <VideoPlayer
        playlistUrl={streamInfo.playlistUrl}
        token={streamInfo.token}
        expires={streamInfo.expiresAt}
        initialPosition={progress?.lastPosition || 0}
        duration={streamInfo.durationSeconds}
        onProgress={(seconds) => setCurrentTime(seconds)}
        onComplete={() => {
          // Could show a "next lesson" modal here
          console.log("Lesson completed!");
        }}
      />
 
      <div className="mt-4 p-4">
        <h1 className="text-2xl font-bold">{lesson?.title}</h1>
        <p className="text-muted-foreground mt-2">{lesson?.description}</p>
      </div>
    </div>
  );
}

Progress Save Flow

Here's the complete progress tracking flow with all save triggers:


Keyboard Shortcuts

The player supports YouTube-style keyboard shortcuts for power users:

KeyAction
Space / KToggle play/pause
Arrow LeftRewind 10 seconds
Arrow RightForward 10 seconds
Arrow UpVolume up 10%
Arrow DownVolume down 10%
FToggle fullscreen
MToggle mute

These are bound globally when the player container is in focus, so they don't conflict with other page interactions.


Testing

1. Test Progress API

# Get progress for a lesson
curl -s http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" | jq
 
# Response (first time):
{ "data": { "watchedSeconds": 0, "lastPosition": 0, "completed": false } }

2. Update Progress

# Save progress
curl -X PUT http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"watchedSeconds": 342, "lastPosition": 340}'
 
# Response:
{ "data": { "watchedSeconds": 342, "lastPosition": 340, "completed": false } }

3. Test Completion

# If lesson duration is 600s, 540s (90%) should trigger completion
curl -X PUT http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"watchedSeconds": 545, "lastPosition": 540}'
 
# Response:
{ "data": { "watchedSeconds": 545, "lastPosition": 540, "completed": true } }

4. Test Beacon Endpoint

# Simulate sendBeacon (POST with token in query)
curl -X POST "http://localhost:8080/api/progress/1/beacon?token=${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"watchedSeconds": 400, "lastPosition": 398}'

5. Test Resume

# Verify the player would resume at the last position
curl -s http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" | jq '.data.lastPosition'
# 398

Common Mistakes

1. Saving Progress on Every timeupdate

The timeupdate event fires 4+ times per second. Sending an API call on each one would overwhelm the backend:

// WRONG — 4+ API calls per second
video.addEventListener("timeupdate", () => {
  fetch(`/api/progress/${lessonId}`, {
    method: "PUT",
    body: JSON.stringify({ watchedSeconds: video.currentTime }),
  });
});
 
// RIGHT — debounce to every 10 seconds
const interval = setInterval(() => {
  saveProgress(video.currentTime);
}, 10_000);

2. Using fetch Instead of sendBeacon on Unload

// WRONG — request will be cancelled
window.addEventListener("beforeunload", () => {
  fetch("/api/progress/1", { method: "PUT", body: data });
  // Browser kills this request before it completes
});
 
// RIGHT — sendBeacon guarantees delivery
window.addEventListener("beforeunload", () => {
  navigator.sendBeacon("/api/progress/1/beacon?token=...", blob);
  // Browser queues this and sends it after the page closes
});

3. Overwriting Higher Watch Counts

If a user rewinds and rewatches a section, don't reduce watchedSeconds:

// WRONG — rewinding reduces progress
progress.setWatchedSeconds(watchedSeconds);
 
// RIGHT — only increase
progress.setWatchedSeconds(Math.max(progress.getWatchedSeconds(), watchedSeconds));

4. Not Handling hls.js Errors

hls.js throws errors for network issues, codec problems, and corrupted segments. If you don't handle them, the player silently freezes:

// WRONG — no error handling
hls.loadSource(src);
hls.attachMedia(video);
// If a segment 404s, player freezes with no feedback
 
// RIGHT — handle recoverable errors
hls.on(Hls.Events.ERROR, (_event, data) => {
  if (data.fatal) {
    if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
      hls.startLoad(); // Retry
    } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
      hls.recoverMediaError(); // Recover
    } else {
      setError("Playback failed");
      hls.destroy();
    }
  }
});

5. Forgetting visibilitychange for Mobile

On mobile, switching tabs doesn't trigger beforeunload. You need visibilitychange:

// Handle mobile tab switching
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    // User switched to another tab or app
    saveProgressViaBeacon();
  }
});

What's Next?

We have a working video player with adaptive streaming, progress tracking, and resume support. In Post #10, we'll integrate Stripe for subscription payments:

  • Stripe Checkout (hosted payment page)
  • Webhook handler for subscription events
  • Subscription lifecycle management (active, canceled, past due)
  • Customer Portal for self-service subscription management
  • Access gating middleware (check subscription before streaming)

Time to turn this platform into a business.

Series: Build a Video Streaming Platform
Previous: Phase 7: Secure Video Streaming
Next: Phase 9: Stripe Subscription Integration

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.