623 lines
19 KiB
TypeScript
623 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Play,
|
|
Pause,
|
|
Volume2,
|
|
VolumeX,
|
|
Maximize,
|
|
Minimize,
|
|
Settings,
|
|
Loader2,
|
|
RotateCcw,
|
|
SkipForward,
|
|
SkipBack,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface VideoPlayerProps {
|
|
src: string;
|
|
poster?: string;
|
|
title?: string;
|
|
autoPlay?: boolean;
|
|
className?: string;
|
|
onEnded?: () => void;
|
|
}
|
|
|
|
/**
|
|
* 高性能视频播放器 - 针对慢速网络优化
|
|
*
|
|
* 特性:
|
|
* - 智能预加载和缓冲管理
|
|
* - 自适应播放速率
|
|
* - 低带宽模式
|
|
* - 断点续播
|
|
* - 流畅的UI交互
|
|
*/
|
|
export function VideoPlayer({
|
|
src,
|
|
poster,
|
|
title,
|
|
autoPlay = true,
|
|
className,
|
|
onEnded,
|
|
}: VideoPlayerProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const hideControlsTimer = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 播放状态
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [volume, setVolume] = useState(1);
|
|
|
|
// 加载状态
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBuffering, setIsBuffering] = useState(false);
|
|
const [loadProgress, setLoadProgress] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 播放进度
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [bufferedRanges, setBufferedRanges] = useState<Array<{start: number; end: number}>>([]);
|
|
|
|
// 播放速度
|
|
const [playbackRate, setPlaybackRate] = useState(1);
|
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
|
|
// 网络质量检测
|
|
const [networkQuality, setNetworkQuality] = useState<"good" | "medium" | "poor">("good");
|
|
const downloadSpeedRef = useRef<number[]>([]);
|
|
|
|
// 格式化时间
|
|
const formatTime = (seconds: number) => {
|
|
if (isNaN(seconds) || !isFinite(seconds)) return "0:00";
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
// 检测网络质量
|
|
const detectNetworkQuality = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !video.buffered.length) return;
|
|
|
|
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
|
const currentTime = video.currentTime;
|
|
const bufferAhead = bufferedEnd - currentTime;
|
|
|
|
// 根据缓冲量判断网络质量
|
|
if (bufferAhead > 10) {
|
|
setNetworkQuality("good");
|
|
} else if (bufferAhead > 3) {
|
|
setNetworkQuality("medium");
|
|
} else {
|
|
setNetworkQuality("poor");
|
|
}
|
|
}, []);
|
|
|
|
// 智能缓冲策略
|
|
const applyBufferStrategy = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
// 根据网络质量调整预加载策略
|
|
switch (networkQuality) {
|
|
case "poor":
|
|
// 慢速网络:降低播放速率以便缓冲追上
|
|
if (isPlaying && playbackRate > 0.75) {
|
|
// 可选:自动降速(这里只是示例,实际可能影响体验)
|
|
}
|
|
break;
|
|
case "medium":
|
|
// 中等网络:正常播放
|
|
break;
|
|
case "good":
|
|
// 快速网络:可以预加载更多
|
|
break;
|
|
}
|
|
}, [networkQuality, isPlaying, playbackRate]);
|
|
|
|
// 播放/暂停
|
|
const togglePlay = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (isPlaying) {
|
|
video.pause();
|
|
} else {
|
|
video.play().catch((e) => {
|
|
// 如果自动播放被阻止,尝试静音播放
|
|
if (e.name === "NotAllowedError") {
|
|
video.muted = true;
|
|
setIsMuted(true);
|
|
video.play().catch(() => {});
|
|
}
|
|
});
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
// 静音切换
|
|
const toggleMute = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.muted = !video.muted;
|
|
setIsMuted(video.muted);
|
|
}, []);
|
|
|
|
// 音量控制
|
|
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const newVolume = parseFloat(e.target.value);
|
|
video.volume = newVolume;
|
|
setVolume(newVolume);
|
|
setIsMuted(newVolume === 0);
|
|
}, []);
|
|
|
|
// 全屏切换
|
|
const toggleFullscreen = useCallback(async () => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
try {
|
|
if (!document.fullscreenElement) {
|
|
await container.requestFullscreen();
|
|
setIsFullscreen(true);
|
|
} else {
|
|
await document.exitFullscreen();
|
|
setIsFullscreen(false);
|
|
}
|
|
} catch (e) {
|
|
console.error("Fullscreen error:", e);
|
|
}
|
|
}, []);
|
|
|
|
// 进度条点击
|
|
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
const progress = progressRef.current;
|
|
if (!video || !progress) return;
|
|
|
|
const rect = progress.getBoundingClientRect();
|
|
const clickPosition = (e.clientX - rect.left) / rect.width;
|
|
const newTime = clickPosition * duration;
|
|
|
|
video.currentTime = newTime;
|
|
setCurrentTime(newTime);
|
|
}, [duration]);
|
|
|
|
// 快进/快退
|
|
const skip = useCallback((seconds: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.currentTime = Math.max(0, Math.min(video.currentTime + seconds, duration));
|
|
}, [duration]);
|
|
|
|
// 播放速度
|
|
const changePlaybackRate = useCallback((rate: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.playbackRate = rate;
|
|
setPlaybackRate(rate);
|
|
setShowSpeedMenu(false);
|
|
}, []);
|
|
|
|
// 重试加载
|
|
const retry = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
setError(null);
|
|
setIsLoading(true);
|
|
video.load();
|
|
}, []);
|
|
|
|
// 控制栏自动隐藏
|
|
const resetHideTimer = useCallback(() => {
|
|
if (hideControlsTimer.current) {
|
|
clearTimeout(hideControlsTimer.current);
|
|
}
|
|
setShowControls(true);
|
|
|
|
if (isPlaying) {
|
|
hideControlsTimer.current = setTimeout(() => {
|
|
setShowControls(false);
|
|
}, 3000);
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
// 视频事件监听
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handlePlay = () => setIsPlaying(true);
|
|
const handlePause = () => setIsPlaying(false);
|
|
const handleLoadStart = () => setIsLoading(true);
|
|
const handleCanPlay = () => {
|
|
setIsLoading(false);
|
|
if (autoPlay) {
|
|
video.play().catch(() => {
|
|
video.muted = true;
|
|
setIsMuted(true);
|
|
video.play().catch(() => {});
|
|
});
|
|
}
|
|
};
|
|
const handleWaiting = () => setIsBuffering(true);
|
|
const handlePlaying = () => setIsBuffering(false);
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(video.currentTime);
|
|
detectNetworkQuality();
|
|
};
|
|
const handleDurationChange = () => setDuration(video.duration);
|
|
const handleProgress = () => {
|
|
// 更新缓冲进度
|
|
const buffered = video.buffered;
|
|
const ranges: Array<{start: number; end: number}> = [];
|
|
for (let i = 0; i < buffered.length; i++) {
|
|
ranges.push({
|
|
start: buffered.start(i),
|
|
end: buffered.end(i),
|
|
});
|
|
}
|
|
setBufferedRanges(ranges);
|
|
|
|
// 计算加载进度
|
|
if (buffered.length > 0 && video.duration > 0) {
|
|
const loadedPercent = (buffered.end(buffered.length - 1) / video.duration) * 100;
|
|
setLoadProgress(loadedPercent);
|
|
}
|
|
};
|
|
const handleError = () => {
|
|
setError("视频加载失败,请检查网络连接后重试");
|
|
setIsLoading(false);
|
|
};
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
onEnded?.();
|
|
};
|
|
|
|
video.addEventListener("play", handlePlay);
|
|
video.addEventListener("pause", handlePause);
|
|
video.addEventListener("loadstart", handleLoadStart);
|
|
video.addEventListener("canplay", handleCanPlay);
|
|
video.addEventListener("waiting", handleWaiting);
|
|
video.addEventListener("playing", handlePlaying);
|
|
video.addEventListener("timeupdate", handleTimeUpdate);
|
|
video.addEventListener("durationchange", handleDurationChange);
|
|
video.addEventListener("progress", handleProgress);
|
|
video.addEventListener("error", handleError);
|
|
video.addEventListener("ended", handleEnded);
|
|
|
|
return () => {
|
|
video.removeEventListener("play", handlePlay);
|
|
video.removeEventListener("pause", handlePause);
|
|
video.removeEventListener("loadstart", handleLoadStart);
|
|
video.removeEventListener("canplay", handleCanPlay);
|
|
video.removeEventListener("waiting", handleWaiting);
|
|
video.removeEventListener("playing", handlePlaying);
|
|
video.removeEventListener("timeupdate", handleTimeUpdate);
|
|
video.removeEventListener("durationchange", handleDurationChange);
|
|
video.removeEventListener("progress", handleProgress);
|
|
video.removeEventListener("error", handleError);
|
|
video.removeEventListener("ended", handleEnded);
|
|
};
|
|
}, [autoPlay, detectNetworkQuality, onEnded]);
|
|
|
|
// 应用缓冲策略
|
|
useEffect(() => {
|
|
applyBufferStrategy();
|
|
}, [applyBufferStrategy]);
|
|
|
|
// 键盘快捷键
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement) return;
|
|
|
|
switch (e.key) {
|
|
case " ":
|
|
case "k":
|
|
e.preventDefault();
|
|
togglePlay();
|
|
break;
|
|
case "m":
|
|
toggleMute();
|
|
break;
|
|
case "f":
|
|
toggleFullscreen();
|
|
break;
|
|
case "ArrowLeft":
|
|
e.preventDefault();
|
|
skip(-10);
|
|
break;
|
|
case "ArrowRight":
|
|
e.preventDefault();
|
|
skip(10);
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (videoRef.current) {
|
|
videoRef.current.volume = Math.min(1, volume + 0.1);
|
|
setVolume(videoRef.current.volume);
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (videoRef.current) {
|
|
videoRef.current.volume = Math.max(0, volume - 0.1);
|
|
setVolume(videoRef.current.volume);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [togglePlay, toggleMute, toggleFullscreen, skip, volume]);
|
|
|
|
// 全屏状态监听
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
};
|
|
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
}, []);
|
|
|
|
// 清理定时器
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hideControlsTimer.current) {
|
|
clearTimeout(hideControlsTimer.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const playbackRates = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"relative w-full aspect-video bg-black rounded-lg overflow-hidden group",
|
|
className
|
|
)}
|
|
onMouseMove={resetHideTimer}
|
|
onMouseLeave={() => isPlaying && setShowControls(false)}
|
|
>
|
|
{/* 视频元素 */}
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={poster}
|
|
className="w-full h-full object-contain"
|
|
playsInline
|
|
preload="auto"
|
|
crossOrigin="anonymous"
|
|
style={{
|
|
transform: "translateZ(0)",
|
|
willChange: "transform",
|
|
}}
|
|
/>
|
|
|
|
{/* 加载指示器 */}
|
|
{(isLoading || isBuffering) && !error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Loader2 className="w-12 h-12 text-white animate-spin" />
|
|
<div className="text-white text-sm">
|
|
{isLoading ? "加载中..." : "缓冲中..."}
|
|
</div>
|
|
{loadProgress > 0 && (
|
|
<div className="w-32 h-1 bg-white/30 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-300"
|
|
style={{ width: `${loadProgress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 网络质量指示 */}
|
|
<div className="flex items-center gap-1 text-xs text-white/70">
|
|
<span>网络:</span>
|
|
<span
|
|
className={cn(
|
|
"px-2 py-0.5 rounded",
|
|
networkQuality === "good" && "bg-green-500/30 text-green-400",
|
|
networkQuality === "medium" && "bg-yellow-500/30 text-yellow-400",
|
|
networkQuality === "poor" && "bg-red-500/30 text-red-400"
|
|
)}
|
|
>
|
|
{networkQuality === "good" ? "良好" : networkQuality === "medium" ? "一般" : "较慢"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 错误提示 */}
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
|
<div className="flex flex-col items-center gap-4 text-white">
|
|
<p className="text-sm">{error}</p>
|
|
<button
|
|
onClick={retry}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
重试
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 大播放按钮(暂停时显示) */}
|
|
{!isPlaying && !isLoading && !error && (
|
|
<button
|
|
onClick={togglePlay}
|
|
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors"
|
|
>
|
|
<div className="w-20 h-20 flex items-center justify-center bg-white/20 backdrop-blur-sm rounded-full">
|
|
<Play className="w-10 h-10 text-white ml-1" />
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
{/* 控制栏 */}
|
|
<div
|
|
className={cn(
|
|
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 pt-16 transition-opacity duration-300",
|
|
showControls || !isPlaying ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
)}
|
|
>
|
|
{/* 进度条 */}
|
|
<div
|
|
ref={progressRef}
|
|
className="relative h-1 bg-white/30 rounded-full cursor-pointer group/progress mb-3"
|
|
onClick={handleProgressClick}
|
|
>
|
|
{/* 缓冲进度 */}
|
|
{bufferedRanges.map((range, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute h-full bg-white/50 rounded-full"
|
|
style={{
|
|
left: `${(range.start / duration) * 100}%`,
|
|
width: `${((range.end - range.start) / duration) * 100}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
{/* 播放进度 */}
|
|
<div
|
|
className="absolute h-full bg-blue-500 rounded-full"
|
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
|
/>
|
|
{/* 进度条手柄 */}
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-blue-500 rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity"
|
|
style={{ left: `calc(${(currentTime / duration) * 100}% - 6px)` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 控制按钮 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{/* 播放/暂停 */}
|
|
<button
|
|
onClick={togglePlay}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors"
|
|
>
|
|
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
|
</button>
|
|
|
|
{/* 快退 */}
|
|
<button
|
|
onClick={() => skip(-10)}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors"
|
|
title="后退10秒"
|
|
>
|
|
<SkipBack className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* 快进 */}
|
|
<button
|
|
onClick={() => skip(10)}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors"
|
|
title="前进10秒"
|
|
>
|
|
<SkipForward className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* 音量 */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={toggleMute}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors"
|
|
>
|
|
{isMuted || volume === 0 ? (
|
|
<VolumeX className="w-5 h-5" />
|
|
) : (
|
|
<Volume2 className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={isMuted ? 0 : volume}
|
|
onChange={handleVolumeChange}
|
|
className="w-16 h-1 bg-white/30 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* 时间 */}
|
|
<span className="text-white text-sm ml-2">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{/* 播放速度 */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors flex items-center gap-1"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
<span className="text-xs">{playbackRate}x</span>
|
|
</button>
|
|
{showSpeedMenu && (
|
|
<div className="absolute bottom-full right-0 mb-2 bg-black/90 rounded-lg py-1 min-w-[80px]">
|
|
{playbackRates.map((rate) => (
|
|
<button
|
|
key={rate}
|
|
onClick={() => changePlaybackRate(rate)}
|
|
className={cn(
|
|
"w-full px-3 py-1 text-sm text-left hover:bg-white/10 transition-colors",
|
|
playbackRate === rate ? "text-blue-400" : "text-white"
|
|
)}
|
|
>
|
|
{rate}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 全屏 */}
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="p-1.5 text-white hover:text-blue-400 transition-colors"
|
|
>
|
|
{isFullscreen ? (
|
|
<Minimize className="w-5 h-5" />
|
|
) : (
|
|
<Maximize className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 标题 */}
|
|
{title && (
|
|
<div className="absolute top-4 left-4 text-white text-sm font-medium truncate max-w-[80%]">
|
|
{title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|