feat: add custom high-performance video player optimized for slow networks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name 2026-01-31 21:00:06 +08:00
parent b99b3e5718
commit 10fecf0892
2 changed files with 641 additions and 45 deletions

View File

@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ImageAnnotator } from "@/components/image-annotator";
import { VideoPlayer } from "@/components/video-player";
type RankingItem = {
rank: number;
@ -478,9 +479,9 @@ export function TopNavbar({
</div>
{/* 视频播放器 */}
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
// YouTube 嵌入 - 启用自动播放和性能优化
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
// YouTube 嵌入 - 启用自动播放和性能优化
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
<iframe
src={`${currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}?autoplay=1&rel=0&modestbranding=1`}
className="w-full h-full"
@ -488,54 +489,27 @@ export function TopNavbar({
allowFullScreen
loading="eager"
/>
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
// Bilibili 嵌入
</div>
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
// Bilibili 嵌入
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
<iframe
src={`${currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}&autoplay=1&high_quality=1`}
className="w-full h-full"
allowFullScreen
loading="eager"
/>
) : (
// 高性能原生视频播放器
<video
src={currentTutorialVideo.videoUrl}
className="w-full h-full object-contain"
controls
autoPlay
playsInline
preload="auto"
controlsList="nodownload"
disablePictureInPicture={false}
poster={currentTutorialVideo.coverUrl || undefined}
onLoadStart={(e) => {
// 优化:设置播放速率缓冲策略
const video = e.currentTarget;
if ('fastSeek' in video) {
video.fastSeek(0);
}
}}
onCanPlay={(e) => {
// 视频可以播放时自动开始
const video = e.currentTarget;
video.play().catch(() => {
// 自动播放被阻止时静音重试
video.muted = true;
video.play().catch(() => {});
});
}}
style={{
// 硬件加速
transform: 'translateZ(0)',
willChange: 'transform',
}}
>
{/* 提供多格式支持作为后备 */}
<source src={currentTutorialVideo.videoUrl} type="video/mp4" />
您的浏览器不支持视频播放
</video>
)}
</div>
</div>
) : (
// 自定义高性能视频播放器 - 针对慢速网络优化
<VideoPlayer
src={currentTutorialVideo.videoUrl}
poster={currentTutorialVideo.coverUrl || undefined}
title={currentTutorialVideo.title}
autoPlay={true}
className="shadow-2xl"
/>
)}
{/* 视频描述 */}
{currentTutorialVideo.description && (

622
components/video-player.tsx Normal file
View File

@ -0,0 +1,622 @@
"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>
);
}