546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect } from 'react'
|
||
|
||
interface VideoPlayerModalProps {
|
||
videoSrc: string
|
||
onClose: () => void
|
||
}
|
||
|
||
export default function VideoPlayerModal({ videoSrc, onClose }: VideoPlayerModalProps) {
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [isBuffering, setIsBuffering] = useState(false)
|
||
const [loadProgress, setLoadProgress] = useState(0)
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [currentTime, setCurrentTime] = useState(0)
|
||
const [duration, setDuration] = useState(0)
|
||
const [volume, setVolume] = useState(1)
|
||
const [isMuted, setIsMuted] = useState(false)
|
||
const [showControls, setShowControls] = useState(true)
|
||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null)
|
||
const [isHoveringProgress, setIsHoveringProgress] = useState(false)
|
||
const [isDraggingProgress, setIsDraggingProgress] = useState(false)
|
||
const [hoverTime, setHoverTime] = useState(0)
|
||
const [hoverPosition, setHoverPosition] = useState(0)
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const progressBarRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 格式化时间显示
|
||
const formatTime = (seconds: number): string => {
|
||
if (!isFinite(seconds) || isNaN(seconds)) return '0:00'
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
// 隐藏控制栏的定时器
|
||
const resetControlsTimeout = () => {
|
||
if (controlsTimeoutRef.current) {
|
||
clearTimeout(controlsTimeoutRef.current)
|
||
}
|
||
setShowControls(true)
|
||
if (isPlaying) {
|
||
controlsTimeoutRef.current = setTimeout(() => {
|
||
setShowControls(false)
|
||
}, 3000)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
// 组件挂载时,视频开始加载
|
||
setIsLoading(true)
|
||
setCurrentTime(0)
|
||
setDuration(0)
|
||
setLoadProgress(0)
|
||
|
||
// 立即开始加载视频(浏览器会利用预加载的缓存)
|
||
if (videoRef.current && videoSrc) {
|
||
videoRef.current.load()
|
||
}
|
||
}, [videoSrc])
|
||
|
||
// 清理定时器和事件监听器
|
||
useEffect(() => {
|
||
return () => {
|
||
// 清理控制栏定时器
|
||
if (controlsTimeoutRef.current) {
|
||
clearTimeout(controlsTimeoutRef.current)
|
||
controlsTimeoutRef.current = null
|
||
}
|
||
|
||
// 清理视频引用,防止内存泄漏
|
||
if (videoRef.current) {
|
||
videoRef.current.pause()
|
||
videoRef.current.src = ''
|
||
videoRef.current.load()
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const handleLoadStart = () => {
|
||
console.log('[VideoPlayer] 开始加载视频')
|
||
setIsLoading(true)
|
||
setLoadProgress(0)
|
||
}
|
||
|
||
const handleLoadedMetadata = () => {
|
||
console.log('[VideoPlayer] 视频元数据加载完成')
|
||
if (videoRef.current) {
|
||
const video = videoRef.current
|
||
setDuration(video.duration)
|
||
// 计算视频宽高比
|
||
if (video.videoWidth && video.videoHeight) {
|
||
setVideoAspectRatio(video.videoWidth / video.videoHeight)
|
||
}
|
||
}
|
||
setIsLoading(false)
|
||
}
|
||
|
||
const handleProgress = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||
const video = e.currentTarget
|
||
if (video.buffered.length > 0 && video.duration > 0) {
|
||
const bufferedEnd = video.buffered.end(video.buffered.length - 1)
|
||
const progress = (bufferedEnd / video.duration) * 100
|
||
setLoadProgress(progress)
|
||
}
|
||
}
|
||
|
||
const handleTimeUpdate = () => {
|
||
if (videoRef.current) {
|
||
setCurrentTime(videoRef.current.currentTime)
|
||
}
|
||
}
|
||
|
||
const handleCanPlay = () => {
|
||
console.log('[VideoPlayer] 视频可以播放')
|
||
setIsLoading(false)
|
||
if (videoRef.current) {
|
||
videoRef.current.play().catch(() => {})
|
||
}
|
||
}
|
||
|
||
const handleCanPlayThrough = () => {
|
||
console.log('[VideoPlayer] 视频可以流畅播放')
|
||
setIsLoading(false)
|
||
setIsBuffering(false)
|
||
}
|
||
|
||
const handleWaiting = () => {
|
||
console.log('[VideoPlayer] 视频缓冲中...')
|
||
setIsBuffering(true)
|
||
}
|
||
|
||
const handlePlaying = () => {
|
||
console.log('[VideoPlayer] 视频开始播放')
|
||
setIsPlaying(true)
|
||
setIsBuffering(false)
|
||
}
|
||
|
||
const handlePause = () => {
|
||
setIsPlaying(false)
|
||
setShowControls(true)
|
||
}
|
||
|
||
const handleEnded = () => {
|
||
setIsPlaying(false)
|
||
setCurrentTime(0)
|
||
if (videoRef.current) {
|
||
videoRef.current.currentTime = 0
|
||
}
|
||
}
|
||
|
||
const handleError = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||
console.error('[VideoPlayer] 视频加载错误:', e)
|
||
setIsLoading(false)
|
||
}
|
||
|
||
// 播放/暂停切换
|
||
const togglePlay = () => {
|
||
if (videoRef.current) {
|
||
if (isPlaying) {
|
||
videoRef.current.pause()
|
||
} else {
|
||
videoRef.current.play().catch(() => {})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 进度条点击或拖拽
|
||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (videoRef.current && duration > 0 && progressBarRef.current) {
|
||
const rect = progressBarRef.current.getBoundingClientRect()
|
||
const clickX = e.clientX - rect.left
|
||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||
const newTime = percentage * duration
|
||
videoRef.current.currentTime = newTime
|
||
setCurrentTime(newTime)
|
||
}
|
||
}
|
||
|
||
// 进度条鼠标按下(开始拖拽)
|
||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
e.preventDefault()
|
||
setIsDraggingProgress(true)
|
||
if (videoRef.current && duration > 0 && progressBarRef.current) {
|
||
const rect = progressBarRef.current.getBoundingClientRect()
|
||
const clickX = e.clientX - rect.left
|
||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||
const newTime = percentage * duration
|
||
videoRef.current.currentTime = newTime
|
||
setCurrentTime(newTime)
|
||
}
|
||
}
|
||
|
||
// 进度条鼠标移动(拖拽中)
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (isDraggingProgress && videoRef.current && duration > 0 && progressBarRef.current) {
|
||
const rect = progressBarRef.current.getBoundingClientRect()
|
||
const clickX = e.clientX - rect.left
|
||
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
||
const newTime = percentage * duration
|
||
videoRef.current.currentTime = newTime
|
||
setCurrentTime(newTime)
|
||
}
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
setIsDraggingProgress(false)
|
||
}
|
||
|
||
if (isDraggingProgress) {
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
}
|
||
}, [isDraggingProgress, duration])
|
||
|
||
// 音量控制
|
||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newVolume = parseFloat(e.target.value)
|
||
setVolume(newVolume)
|
||
if (videoRef.current) {
|
||
videoRef.current.volume = newVolume
|
||
setIsMuted(newVolume === 0)
|
||
}
|
||
}
|
||
|
||
// 静音切换
|
||
const toggleMute = () => {
|
||
if (videoRef.current) {
|
||
if (isMuted) {
|
||
videoRef.current.volume = volume || 0.5
|
||
setIsMuted(false)
|
||
} else {
|
||
videoRef.current.volume = 0
|
||
setIsMuted(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 全屏切换
|
||
const toggleFullscreen = () => {
|
||
if (containerRef.current) {
|
||
if (!document.fullscreenElement) {
|
||
containerRef.current.requestFullscreen().catch(() => {})
|
||
} else {
|
||
document.exitFullscreen().catch(() => {})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 节流鼠标移动事件,避免频繁调用导致性能问题
|
||
const mouseMoveThrottleRef = useRef<NodeJS.Timeout | null>(null)
|
||
|
||
// 鼠标移动显示控制栏(节流处理)
|
||
const handleMouseMove = () => {
|
||
// 如果已经有待处理的调用,跳过
|
||
if (mouseMoveThrottleRef.current) {
|
||
return
|
||
}
|
||
|
||
// 立即执行一次
|
||
resetControlsTimeout()
|
||
|
||
// 设置节流,200ms内不再处理
|
||
mouseMoveThrottleRef.current = setTimeout(() => {
|
||
mouseMoveThrottleRef.current = null
|
||
}, 200)
|
||
}
|
||
|
||
// 鼠标离开隐藏控制栏(仅在播放时)
|
||
const handleMouseLeave = () => {
|
||
// 清理节流定时器
|
||
if (mouseMoveThrottleRef.current) {
|
||
clearTimeout(mouseMoveThrottleRef.current)
|
||
mouseMoveThrottleRef.current = null
|
||
}
|
||
|
||
if (isPlaying && controlsTimeoutRef.current) {
|
||
clearTimeout(controlsTimeoutRef.current)
|
||
controlsTimeoutRef.current = setTimeout(() => {
|
||
setShowControls(false)
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
// 清理节流定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (mouseMoveThrottleRef.current) {
|
||
clearTimeout(mouseMoveThrottleRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
ref={containerRef}
|
||
className="relative w-full max-w-7xl video-player-modal"
|
||
style={{
|
||
maxHeight: 'calc(100vh - 2rem)',
|
||
maxWidth: 'calc(100vw - 2rem)'
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseLeave={handleMouseLeave}
|
||
>
|
||
{/* 视频容器 - 根据视频实际宽高比动态调整 */}
|
||
<div
|
||
className="relative w-full bg-black rounded-lg shadow-2xl overflow-hidden"
|
||
style={{
|
||
// 如果有视频宽高比,使用它;否则使用16:9作为默认值
|
||
aspectRatio: videoAspectRatio ? `${videoAspectRatio}` : '16/9',
|
||
maxHeight: 'calc(100vh - 8rem)', // 为控制栏留出空间
|
||
maxWidth: 'calc(100vw - 2rem)'
|
||
}}
|
||
>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoSrc}
|
||
autoPlay
|
||
preload="auto"
|
||
playsInline
|
||
className="w-full h-full object-contain"
|
||
onLoadStart={handleLoadStart}
|
||
onLoadedMetadata={handleLoadedMetadata}
|
||
onProgress={handleProgress}
|
||
onTimeUpdate={handleTimeUpdate}
|
||
onCanPlay={handleCanPlay}
|
||
onCanPlayThrough={handleCanPlayThrough}
|
||
onWaiting={handleWaiting}
|
||
onPlaying={handlePlaying}
|
||
onPause={handlePause}
|
||
onEnded={handleEnded}
|
||
onError={handleError}
|
||
onClick={togglePlay}
|
||
/>
|
||
|
||
{/* 加载状态指示器 */}
|
||
{(isLoading || isBuffering) && (
|
||
<div className="absolute inset-0 flex items-center justify-center z-[100] pointer-events-none bg-black/50">
|
||
<div className="text-center bg-black/70 rounded-lg px-6 py-4 backdrop-blur-sm">
|
||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mb-4"></div>
|
||
<p className="text-white text-sm font-medium">
|
||
{isLoading ? '正在加载视频...' : '视频缓冲中...'}
|
||
</p>
|
||
{loadProgress > 0 && (
|
||
<div className="mt-3 w-64 bg-gray-700 rounded-full h-2 overflow-hidden">
|
||
<div
|
||
className="bg-blue-500 h-full transition-all duration-300"
|
||
style={{ width: `${loadProgress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 自定义控制栏 */}
|
||
<div
|
||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent transition-opacity duration-300 ${
|
||
showControls ? 'opacity-100' : 'opacity-0'
|
||
}`}
|
||
style={{ zIndex: 50 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 进度条容器 - 相对定位以便显示时间提示 */}
|
||
<div className="relative px-2">
|
||
{/* 进度条 - 增强交互体验 */}
|
||
<div
|
||
ref={progressBarRef}
|
||
className={`w-full cursor-pointer transition-all duration-200 relative ${
|
||
isHoveringProgress || isDraggingProgress ? 'h-3 py-1' : 'h-2'
|
||
}`}
|
||
onClick={handleProgressClick}
|
||
onMouseDown={handleProgressMouseDown}
|
||
onMouseEnter={() => setIsHoveringProgress(true)}
|
||
onMouseLeave={() => {
|
||
if (!isDraggingProgress) {
|
||
setIsHoveringProgress(false)
|
||
setHoverTime(0)
|
||
setHoverPosition(0)
|
||
}
|
||
}}
|
||
onMouseMove={(e) => {
|
||
if (progressBarRef.current && duration > 0) {
|
||
const rect = progressBarRef.current.getBoundingClientRect()
|
||
const mouseX = e.clientX - rect.left
|
||
const percentage = Math.max(0, Math.min(1, mouseX / rect.width))
|
||
setHoverPosition(percentage * 100)
|
||
setHoverTime(percentage * duration)
|
||
}
|
||
}}
|
||
>
|
||
{/* 进度条背景 */}
|
||
<div className={`w-full bg-gray-700/60 rounded-full transition-all duration-200 ${
|
||
isHoveringProgress || isDraggingProgress ? 'h-2' : 'h-1'
|
||
}`}>
|
||
{/* 已播放进度 */}
|
||
<div
|
||
className={`bg-blue-500 rounded-full transition-all duration-100 relative ${
|
||
isHoveringProgress || isDraggingProgress ? 'h-2' : 'h-1'
|
||
}`}
|
||
style={{ width: `${progressPercentage}%` }}
|
||
>
|
||
{/* 可拖拽的滑块指示器 */}
|
||
<div
|
||
className={`absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 bg-blue-500 rounded-full transition-all duration-200 border-2 border-white ${
|
||
isHoveringProgress || isDraggingProgress
|
||
? 'w-4 h-4 opacity-100 shadow-lg shadow-blue-500/50'
|
||
: 'w-0 h-0 opacity-0'
|
||
}`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 悬停时的预览时间提示 - 显示鼠标位置对应的时间 */}
|
||
{isHoveringProgress && !isDraggingProgress && progressBarRef.current && duration > 0 && hoverTime > 0 && (
|
||
<div
|
||
className="absolute bottom-full mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded pointer-events-none whitespace-nowrap z-10"
|
||
style={{
|
||
left: `${hoverPosition}%`,
|
||
transform: 'translateX(-50%)'
|
||
}}
|
||
>
|
||
{formatTime(hoverTime)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 控制按钮栏 */}
|
||
<div className="flex items-center gap-4 px-4 py-3">
|
||
{/* 播放/暂停按钮 */}
|
||
<button
|
||
onClick={togglePlay}
|
||
className="text-white hover:text-blue-400 transition-colors flex-shrink-0"
|
||
aria-label={isPlaying ? '暂停' : '播放'}
|
||
>
|
||
{isPlaying ? (
|
||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
|
||
{/* 时间显示 */}
|
||
<div className="text-white text-sm font-mono flex-shrink-0">
|
||
{formatTime(currentTime)} / {formatTime(duration)}
|
||
</div>
|
||
|
||
{/* 音量控制 */}
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<button
|
||
onClick={toggleMute}
|
||
className="text-white hover:text-blue-400 transition-colors"
|
||
aria-label={isMuted ? '取消静音' : '静音'}
|
||
>
|
||
{isMuted || volume === 0 ? (
|
||
<svg className="w-5 h-5" 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>
|
||
) : volume < 0.5 ? (
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M18.83 16h-1.5l-1.5-1.5v-5l1.5-1.5h1.5v8zM5 9v6h4l5 5V4L9 9H5z" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-5 h-5" 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.01"
|
||
value={isMuted ? 0 : volume}
|
||
onChange={handleVolumeChange}
|
||
className="w-20 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||
style={{
|
||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(isMuted ? 0 : volume) * 100}%, #374151 ${(isMuted ? 0 : volume) * 100}%, #374151 100%)`
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 右侧按钮组 */}
|
||
<div className="flex items-center gap-2 ml-auto">
|
||
{/* 全屏按钮 */}
|
||
<button
|
||
onClick={toggleFullscreen}
|
||
className="text-white hover:text-blue-400 transition-colors"
|
||
aria-label="全屏"
|
||
>
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v-2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:text-red-400 transition-colors"
|
||
aria-label="关闭"
|
||
>
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 隐藏原生视频控件 */}
|
||
<style jsx global>{`
|
||
.video-player-modal video {
|
||
/* 隐藏原生控件 */
|
||
}
|
||
.video-player-modal video::-webkit-media-controls {
|
||
display: none !important;
|
||
}
|
||
.video-player-modal video::-webkit-media-controls-enclosure {
|
||
display: none !important;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
}
|