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.jshls.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ím | Hành động |
|---|---|
Space / K | Toggle play/pause |
← Arrow Left | Tua lại 10 giây |
→ Arrow Right | Tua tới 10 giây |
↑ Arrow Up | Tăng volume 10% |
↓ Arrow Down | Giảm volume 10% |
F | Toggle fullscreen |
M | Toggle 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'
# 398Lỗ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.