Files
2026-03-10 01:35:37 -05:00

370 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLVideoElement>(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<ReturnType<typeof setTimeout> | 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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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 (
<div
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
title={title}
className="aspect-video w-full object-contain"
onClick={togglePlay}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPause={handlePause}
playsInline
/>
{/* 续播提示 - 位于视频左下角上方 */}
{showResumePrompt && (
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
<p className="mb-3 text-sm text-white/90">
<span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleResume}
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
>
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
>
</button>
</div>
</div>
)}
{/* 中央播放按钮 */}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
aria-label="播放"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
{/* 底部控制栏 */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
{/* 进度条 */}
<div
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
onClick={handleSeek}
role="slider"
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<button
type="button"
onClick={togglePlay}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={playing ? "暂停" : "播放"}
>
{playing ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={muted ? "取消静音" : "静音"}
>
{muted || volume === 0 ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
/>
</div>
<button
type="button"
onClick={cycleSpeed}
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
>
{speed}x
</button>
<button
type="button"
onClick={toggleFullscreen}
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
aria-label="全屏"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
);
}