Back to blog

Xây Dựng Video Platform: Video Player & Progress Tracking

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Video Player & Progress Tracking

Streams đã được bảo mật, Nginx đang serve HLS segments, signed URLs hoạt động ngon lành. Nhưng bây giờ, cách duy nhất để xem video là paste URL vào ffplay. User cần một player thật sự — một player tự động chuyển quality, nhớ vị trí đã xem, và cho phép điều chỉnh playback speed.

Trong bài này, ta sẽ build custom React video player dùng hls.js. Không phải wrapper bọc player của người khác — mà là player ta kiểm soát hoàn toàn, với adaptive streaming, progress tracking, và UI gọn gàng từ các component tự build.

Thời gian: 3–4 giờ
Yêu cầu: Phase 7: Secure Video Streaming

Những gì ta sẽ build trong bài này:
✅ hls.js integration với React cho adaptive HLS playback
✅ Quality selector (auto, 360p, 720p)
✅ Playback speed control (0.5x đến 2x)
✅ Custom player controls (play/pause, seek bar, volume, fullscreen)
✅ Resume từ vị trí cuối khi load trang
✅ Debounced progress saves lên backend
✅ sendBeacon để lưu progress khi thoát trang
✅ Backend API cho lesson progress tracking


Tổng Quan Architecture


Setup hls.js

Cài Đặt

cd web
npm install hls.js

hls.js là JavaScript library phát HLS streams trên mọi browser hỗ trợ MSE (Media Source Extensions). Safari có native HLS support, nên hls.js gracefully fallback sang native player trên Safari.

HLS Hook

Tạo custom React hook quản lý vòng đời hls.js:

// 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 tự quyết định)
    hlsRef.current.currentLevel = index;
    setCurrentQuality(index);
  }, []);
 
  useEffect(() => {
    if (!src || !config || !videoRef.current) return;
 
    const video = videoRef.current;
 
    // Safari hỗ trợ HLS native
    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 vào mọi HLS request
        const separator = url.includes("?") ? "&" : "?";
        const signedUrl = `${url}${separator}token=${config.token}&expires=${config.expires}`;
        xhr.open("GET", signedUrl, true);
      },
      // Bắt đầu với quality thấp nhất cho initial load nhanh
      startLevel: 0,
      // Bật 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:
            // Thử recover từ 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 quality hiện tại khi ABR chuyển
      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,
  };
}

Các quyết định design quan trọng:

  • Token injection qua xhrSetup: Mọi XHR request mà hls.js tạo đều được append signed token. Điều này cover master playlist, quality playlists, và tất cả segment requests.
  • Safari fallback: Safari hỗ trợ HLS native qua thẻ <video>, nên ta skip hls.js hoàn toàn.
  • Error recovery: Network errors tự động retry. Media errors thử recover. Chỉ truly fatal errors mới dừng playback.
  • ABR: Adaptive bitrate bắt đầu với estimate 500kbps và điều chỉnh dựa trên bandwidth thực tế.

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 từ vị trí cuối
  useEffect(() => {
    if (!isReady || !videoRef.current || hasResumedRef.current) return;
    if (initialPosition > 0) {
      videoRef.current.currentTime = initialPosition;
    }
    hasResumedRef.current = true;
  }, [isReady, initialPosition, videoRef]);
 
  // Theo dõi 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]);
 
  // Tự động ẩn 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) => {
      // Chỉ xử lý khi player đang 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);
 
  // Đóng menu khi click ngoài
  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]">
          {/* Tùy chọn Auto */}
          <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 (cao nhất trước) */}
          {[...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

Vấn Đề

User mong đợi tiếp tục xem từ nơi họ dừng lại. Nếu ai đó xem 15 phút của bài giảng 30 phút, đóng tab, quay lại ngày mai — player phải bắt đầu từ 15:00, không phải 0:00.

Ta cũng cần track completion cho course progress dashboard (user đã hoàn thành bao nhiêu phần trăm course?).

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% xem = hoàn thành
 
    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;
                });
 
        // Chỉ update nếu position mới thực sự lớn hơn
        // (ngăn việc tua lại giảm watched seconds)
        progress.setWatchedSeconds(Math.max(progress.getWatchedSeconds(), watchedSeconds));
        progress.setLastPosition(lastPosition);
        progress.setUpdatedAt(LocalDateTime.now());
 
        // Đánh dấu hoàn thành nếu đã xem >= 90% bài giảng
        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

Lưu progress trên mỗi sự kiện timeupdate (fire ~4 lần/giây) sẽ flood API. Thay vào đó, ta debounce: lưu tối đa mỗi 10 giây, và luôn lưu khi user rời trang.

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; // Lưu mỗi 10 giây
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);
 
  // Giữ refs đồng bộ
  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) {
        // Fail im lặng — sẽ lưu lại sau
        console.error("Failed to save progress:", error);
      }
    },
    [lessonId, enabled]
  );
 
  // Lưu định kỳ khi đang phát
  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]);
 
  // Lưu khi pause
  useEffect(() => {
    if (!isPlaying && enabled && currentTimeRef.current > 0) {
      saveProgress(
        Math.floor(currentTimeRef.current),
        maxWatchedRef.current
      );
    }
  }, [isPlaying, enabled, saveProgress]);
 
  // Lưu khi thoát trang dùng 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 đảm bảo gửi được ngay cả khi trang đang đóng
      const data = JSON.stringify({
        watchedSeconds: watched,
        lastPosition: position,
      });
 
      const blob = new Blob([data], { type: "application/json" });
 
      // sendBeacon không hỗ trợ custom headers, nên dùng endpoint đặc biệt
      navigator.sendBeacon(
        `${API_BASE}/api/progress/${lessonId}/beacon?token=${token}`,
        blob
      );
    };
 
    window.addEventListener("beforeunload", handleBeforeUnload);
    // Cũng lưu khi visibility thay đổi (chuyển tab trên mobile)
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        handleBeforeUnload();
      }
    });
 
    return () => {
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, [lessonId, enabled]);
}

Beacon Endpoint

sendBeacon không thể set custom HTTP headers như Authorization. Ta cần endpoint đặc biệt nhận token qua query parameter:

// Thêm vào ProgressController.java
 
@PostMapping("/{lessonId}/beacon")
public ResponseEntity<Void> beaconProgress(
        @PathVariable Long lessonId,
        @RequestParam("token") String token,
        @RequestBody ProgressUpdateRequest request) {
 
    // Validate JWT token thủ công (không qua 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();
}

Tại Sao sendBeacon?

Request fetch thông thường bị cancel khi trang unload — browser kill pending XHRs khi bạn đóng tab hoặc navigate đi. sendBeacon được thiết kế đặc biệt cho use case này: nó queue request và đảm bảo delivery ngay cả sau khi trang đã biến mất.

Hạn chế:

  • Không set được custom headers (nên token phải nằm trong query string)
  • Giới hạn payload size (~64KB, dư dả cho use case của ta)
  • Chỉ POST (không PUT)

Ghép Tất Cả Lại: 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 và progress khi mount
  useEffect(() => {
    async function loadLesson() {
      try {
        const token = localStorage.getItem("accessToken");
        const headers = { Authorization: `Bearer ${token}` };
 
        // Fetch stream URL và progress song song
        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={() => {
          // Có thể hiển thị "bài tiếp theo" modal ở đây
          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

Đây là toàn bộ flow progress tracking với tất cả save triggers:


Keyboard Shortcuts

Player hỗ trợ keyboard shortcuts kiểu YouTube cho power users:

PhímHành động
Space / KToggle play/pause
← Arrow LeftTua lại 10 giây
→ Arrow RightTua tới 10 giây
↑ Arrow UpTăng volume 10%
↓ Arrow DownGiảm volume 10%
FToggle fullscreen
MToggle mute

Các phím này được bind globally khi player container đang focus, nên không conflict với các tương tác khác trên trang.


Testing

1. Test Progress API

# Lấy progress cho một lesson
curl -s http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" | jq
 
# Response (lần đầu):
{ "data": { "watchedSeconds": 0, "lastPosition": 0, "completed": false } }

2. Update Progress

# Lưu 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

# Nếu lesson duration là 600s, 540s (90%) sẽ 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

# Mô phỏng sendBeacon (POST với token trong 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

# Xác nhận player sẽ resume tại vị trí cuối
curl -s http://localhost:8080/api/progress/1 \
  -H "Authorization: Bearer ${TOKEN}" | jq '.data.lastPosition'
# 398

Lỗi Thường Gặp

1. Lưu Progress Trên Mỗi timeupdate

Sự kiện timeupdate fire 4+ lần/giây. Gửi API call trên mỗi lần sẽ đánh sập backend:

// SAI — 4+ API calls mỗi giây
video.addEventListener("timeupdate", () => {
  fetch(`/api/progress/${lessonId}`, {
    method: "PUT",
    body: JSON.stringify({ watchedSeconds: video.currentTime }),
  });
});
 
// ĐÚNG — debounce mỗi 10 giây
const interval = setInterval(() => {
  saveProgress(video.currentTime);
}, 10_000);

2. Dùng fetch Thay Vì sendBeacon Khi Unload

// SAI — request sẽ bị cancel
window.addEventListener("beforeunload", () => {
  fetch("/api/progress/1", { method: "PUT", body: data });
  // Browser kill request này trước khi nó hoàn thành
});
 
// ĐÚNG — sendBeacon đảm bảo delivery
window.addEventListener("beforeunload", () => {
  navigator.sendBeacon("/api/progress/1/beacon?token=...", blob);
  // Browser queue và gửi sau khi trang đóng
});

3. Ghi Đè Watch Count Cao Hơn

Nếu user tua lại và xem lại một phần, đừng giảm watchedSeconds:

// SAI — tua lại giảm progress
progress.setWatchedSeconds(watchedSeconds);
 
// ĐÚNG — chỉ tăng
progress.setWatchedSeconds(Math.max(progress.getWatchedSeconds(), watchedSeconds));

4. Không Xử Lý hls.js Errors

hls.js throw errors cho network issues, codec problems, và corrupted segments. Nếu bạn không xử lý, player sẽ đơ im lặng:

// SAI — không xử lý error
hls.loadSource(src);
hls.attachMedia(video);
// Nếu segment 404, player đơ không có feedback
 
// ĐÚNG — xử lý 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. Quên visibilitychange Cho Mobile

Trên mobile, chuyển tab không trigger beforeunload. Bạn cần visibilitychange:

// Xử lý chuyển tab trên mobile
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    // User chuyển sang tab hoặc app khác
    saveProgressViaBeacon();
  }
});

Tiếp Theo?

Ta đã có video player hoạt động với adaptive streaming, progress tracking, và resume support. Trong Post #10, ta sẽ integrate Stripe cho subscription payments:

  • Stripe Checkout (hosted payment page)
  • Webhook handler cho subscription events
  • Subscription lifecycle management (active, canceled, past due)
  • Customer Portal cho self-service subscription management
  • Access gating middleware (check subscription trước khi streaming)

Đã đến lúc biến platform này thành business.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 7: Secure Video Streaming
Tiếp theo: Phase 9: Stripe Subscription Integration

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.