gongxiangp2/components/VideoPlayerModal.tsx

546 lines
19 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 { 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>
)
}