363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
const STORAGE_PREFIX = "lms-video-";
|
|
const SAVE_INTERVAL = 5000;
|
|
const MIN_PROGRESS_TO_SHOW = 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;
|
|
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>
|
|
);
|
|
}
|