"use client"; import { useCallback, useEffect, useRef, useState } from "react"; const STORAGE_PREFIX = "lms-video-"; const SAVE_INTERVAL = 5000; // 每 5 秒保存一次 const MIN_PROGRESS_TO_SHOW = 10; // 至少观看 10 秒才提示续播 function getStoredProgress(videoId: string): number { if (typeof window === "undefined") return 0; try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`); if (!raw) return 0; const data = JSON.parse(raw); return typeof data?.time === "number" ? data.time : 0; } catch { return 0; } } function saveProgress(videoId: string, time: number): void { if (typeof window === "undefined") return; try { localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time })); } catch { /* ignore */ } } type VideoPlayerProps = { src: string; poster?: string; title?: string; className?: string; /** 用于存储观看进度的唯一 ID,如 course-0-1 */ videoId?: string; /** 播完时回调,用于标记完成 */ onComplete?: () => void; }; export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) { const videoRef = useRef(null); const [playing, setPlaying] = useState(false); const [progress, setProgress] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [muted, setMuted] = useState(false); const [speed, setSpeed] = useState(1); const [showControls, setShowControls] = useState(true); const [showResumePrompt, setShowResumePrompt] = useState(false); const [savedTime, setSavedTime] = useState(0); const hideTimerRef = useRef | null>(null); const lastSaveRef = useRef(0); const togglePlay = useCallback(() => { const v = videoRef.current; if (!v) return; if (v.paused) { v.play(); setPlaying(true); } else { v.pause(); setPlaying(false); } }, []); const handleTimeUpdate = useCallback(() => { const v = videoRef.current; if (!v) return; const time = v.currentTime; const dur = v.duration; setCurrentTime(time); setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0); // 定期保存观看进度 if (videoId && v.currentTime > 0) { const now = Date.now(); if (now - lastSaveRef.current > SAVE_INTERVAL) { lastSaveRef.current = now; saveProgress(videoId, v.currentTime); } } }, [videoId]); const handleLoadedMetadata = useCallback(() => { const v = videoRef.current; if (!v) return; const dur = v.duration; if (!Number.isFinite(dur) || dur <= 0) return; setDuration(dur); // 检查是否有上次观看进度 if (videoId) { const stored = getStoredProgress(videoId); if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) { setSavedTime(stored); setShowResumePrompt(true); } } }, [videoId]); const handleEnded = useCallback(() => { setPlaying(false); setProgress(0); setCurrentTime(0); if (videoId) saveProgress(videoId, 0); // 播完清除进度 onComplete?.(); }, [videoId, onComplete]); const handlePause = useCallback(() => { const v = videoRef.current; if (videoId && v && v.currentTime > 0) { saveProgress(videoId, v.currentTime); } }, [videoId]); const handleResume = useCallback(() => { const v = videoRef.current; if (!v || savedTime <= 0) return; const dur = v.duration; if (!Number.isFinite(dur) || dur <= 0) return; const time = Math.min(savedTime, dur - 0.1); v.currentTime = time; setCurrentTime(time); setProgress((time / dur) * 100); v.play(); setPlaying(true); setShowResumePrompt(false); }, [savedTime]); const handleRestart = useCallback(() => { const v = videoRef.current; if (!v) return; if (videoId) saveProgress(videoId, 0); v.currentTime = 0; setCurrentTime(0); setProgress(0); setShowResumePrompt(false); }, [videoId]); const handleSeek = useCallback((e: React.MouseEvent) => { const v = videoRef.current; if (!v) return; const dur = v.duration; if (!Number.isFinite(dur) || dur <= 0) return; const rect = e.currentTarget.getBoundingClientRect(); if (rect.width <= 0) return; const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const time = p * dur; v.currentTime = time; setCurrentTime(time); setProgress(p * 100); }, []); const toggleMute = useCallback(() => { const v = videoRef.current; if (!v) return; v.muted = !v.muted; setMuted(v.muted); }, []); const handleVolumeChange = useCallback((e: React.ChangeEvent) => { const v = videoRef.current; if (!v) return; const val = parseFloat(e.target.value); v.volume = val; setVolume(val); setMuted(val === 0); }, []); const cycleSpeed = useCallback(() => { const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; const i = speeds.indexOf(speed); const next = speeds[(i + 1) % speeds.length]; const v = videoRef.current; if (v) v.playbackRate = next; setSpeed(next); }, [speed]); const toggleFullscreen = useCallback(() => { const container = videoRef.current?.parentElement; if (!container) return; if (!document.fullscreenElement) { container.requestFullscreen(); } else { document.exitFullscreen(); } }, []); const showControlsTemporarily = useCallback(() => { setShowControls(true); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); hideTimerRef.current = setTimeout(() => setShowControls(false), 3000); }, []); useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []); const formatTime = (s: number) => { if (!Number.isFinite(s) || s < 0) return "0:00"; const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${sec.toString().padStart(2, "0")}`; }; const formatResumeTime = (s: number) => { if (!Number.isFinite(s) || s < 0) return "0秒"; const m = Math.floor(s / 60); const sec = Math.floor(s % 60); if (m > 0) return `${m}分${sec}秒`; return `${sec}秒`; }; return (
setShowControls(false)} >