Spaces:
Running
Running
import React, { useState, useEffect, useRef } from 'react'; | |
import { ArrowLeft, FastForward, Keyboard, Maximize, Minimize, Pause, Play, Rewind, SkipBack, SkipForward, Volume2, VolumeX, X } from 'lucide-react'; | |
import { formatTime } from '../lib/utils'; | |
interface VideoPlayerProps { | |
url: string; | |
title?: string; | |
poster?: string; | |
startTime?: number; | |
onClose?: () => void; | |
onProgressUpdate?: (currentTime: number, duration: number) => void; | |
onVideoEnded?: () => void; | |
showNextButton?: boolean; | |
contentRating?: { rating: string, description: string } | null; | |
hideTitleInPlayer?: boolean; | |
showControls?: boolean; | |
containerRef?: React.RefObject<HTMLDivElement>; | |
videoRef?: React.RefObject<HTMLVideoElement>; | |
customOverlay?: React.ReactNode; | |
} | |
const VideoPlayer: React.FC<VideoPlayerProps> = ({ | |
url, | |
title, | |
poster, | |
startTime = 0, | |
onClose, | |
onProgressUpdate, | |
onVideoEnded, | |
showNextButton = false, | |
contentRating, | |
hideTitleInPlayer = false, | |
showControls: initialShowControls = true, | |
containerRef, | |
videoRef: externalVideoRef, | |
customOverlay | |
}) => { | |
const internalVideoRef = useRef<HTMLVideoElement>(null); | |
const videoRef = externalVideoRef || internalVideoRef; | |
const [isPlaying, setIsPlaying] = useState(false); | |
const [volume, setVolume] = useState(1); | |
const [isMuted, setIsMuted] = useState(false); | |
const [progress, setProgress] = useState(startTime); | |
const [duration, setDuration] = useState(0); | |
const [showControls, setShowControls] = useState(initialShowControls); | |
const [isFullscreen, setIsFullscreen] = useState(false); | |
const [buffered, setBuffered] = useState(0); | |
const [showRating, setShowRating] = useState(true); | |
const [hoverTime, setHoverTime] = useState<number | null>(null); | |
const [hoverPosition, setHoverPosition] = useState<{ x: number, y: number } | null>(null); | |
const [showKeyboardControls, setShowKeyboardControls] = useState(false); | |
const controlsTimerRef = useRef<NodeJS.Timeout | null>(null); | |
const playerContainerRef = useRef<HTMLDivElement>(null); | |
const progressBarRef = useRef<HTMLDivElement>(null); | |
const ratingTimerRef = useRef<NodeJS.Timeout | null>(null); | |
// Format time manually (in case utils import fails) | |
const formatTimeBackup = (time: number): string => { | |
const hours = Math.floor(time / 3600); | |
const minutes = Math.floor((time % 3600) / 60); | |
const seconds = Math.floor(time % 60); | |
const minutesStr = minutes.toString().padStart(2, '0'); | |
const secondsStr = seconds.toString().padStart(2, '0'); | |
return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`; | |
}; | |
// Hide content rating after a few seconds | |
useEffect(() => { | |
if (showRating && contentRating) { | |
ratingTimerRef.current = setTimeout(() => { | |
setShowRating(false); | |
}, 8000); | |
} | |
return () => { | |
if (ratingTimerRef.current) { | |
clearTimeout(ratingTimerRef.current); | |
} | |
}; | |
}, [showRating, contentRating]); | |
useEffect(() => { | |
const videoElement = videoRef.current; | |
if (videoElement) { | |
const handleLoadedMetadata = () => { | |
setDuration(videoElement.duration); | |
videoElement.currentTime = startTime; | |
setProgress(startTime); | |
}; | |
const handleTimeUpdate = () => { | |
setProgress(videoElement.currentTime); | |
onProgressUpdate?.(videoElement.currentTime, videoElement.duration); | |
}; | |
const handleEnded = () => { | |
setIsPlaying(false); | |
onVideoEnded?.(); | |
}; | |
const handleBufferUpdate = () => { | |
if (videoElement.buffered.length > 0) { | |
setBuffered(videoElement.buffered.end(videoElement.buffered.length - 1)); | |
} | |
}; | |
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata); | |
videoElement.addEventListener('timeupdate', handleTimeUpdate); | |
videoElement.addEventListener('ended', handleEnded); | |
videoElement.addEventListener('progress', handleBufferUpdate); | |
return () => { | |
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata); | |
videoElement.removeEventListener('timeupdate', handleTimeUpdate); | |
videoElement.removeEventListener('ended', handleEnded); | |
videoElement.removeEventListener('progress', handleBufferUpdate); | |
}; | |
} | |
}, [url, startTime, onProgressUpdate, onVideoEnded, videoRef]); | |
useEffect(() => { | |
if (isPlaying) { | |
videoRef.current?.play(); | |
} else { | |
videoRef.current?.pause(); | |
} | |
}, [isPlaying, videoRef]); | |
useEffect(() => { | |
if (videoRef.current) { | |
videoRef.current.volume = isMuted ? 0 : volume; | |
} | |
}, [volume, isMuted, videoRef]); | |
const hideControlsTimer = () => { | |
if (controlsTimerRef.current) { | |
clearTimeout(controlsTimerRef.current); | |
} | |
controlsTimerRef.current = setTimeout(() => { | |
if (isPlaying && !showKeyboardControls) { | |
setShowControls(false); | |
} | |
}, 3000); | |
}; | |
const handleMouseMove = () => { | |
setShowControls(true); | |
hideControlsTimer(); | |
}; | |
useEffect(() => { | |
hideControlsTimer(); | |
return () => { | |
if (controlsTimerRef.current) { | |
clearTimeout(controlsTimerRef.current); | |
} | |
}; | |
}, [isPlaying, showKeyboardControls]); | |
// Keyboard controls | |
useEffect(() => { | |
const handleKeyDown = (e: KeyboardEvent) => { | |
switch (e.key) { | |
case ' ': | |
case 'k': | |
e.preventDefault(); | |
setIsPlaying(prev => !prev); | |
setShowControls(true); | |
break; | |
case 'ArrowRight': | |
e.preventDefault(); | |
skipForward(); | |
setShowControls(true); | |
break; | |
case 'ArrowLeft': | |
e.preventDefault(); | |
skipBackward(); | |
setShowControls(true); | |
break; | |
case 'f': | |
e.preventDefault(); | |
toggleFullscreen(); | |
break; | |
case 'm': | |
e.preventDefault(); | |
setIsMuted(prev => !prev); | |
setShowControls(true); | |
break; | |
case '?': | |
e.preventDefault(); | |
setShowKeyboardControls(prev => !prev); | |
setShowControls(true); | |
break; | |
case 'Escape': | |
if (showKeyboardControls) { | |
setShowKeyboardControls(false); | |
} else if (isFullscreen) { | |
document.exitFullscreen(); | |
} else if (onClose) { | |
onClose(); | |
} | |
break; | |
} | |
}; | |
document.addEventListener('keydown', handleKeyDown); | |
return () => document.removeEventListener('keydown', handleKeyDown); | |
}, [isFullscreen, onClose, showKeyboardControls]); | |
// Fullscreen handlers | |
useEffect(() => { | |
const handleFullScreenChange = () => { | |
setIsFullscreen(document.fullscreenElement === (containerRef?.current || playerContainerRef.current)); | |
}; | |
document.addEventListener('fullscreenchange', handleFullScreenChange); | |
return () => document.removeEventListener('fullscreenchange', handleFullScreenChange); | |
}, [containerRef]); | |
const toggleFullscreen = async () => { | |
const fullscreenElement = containerRef?.current || playerContainerRef.current; | |
if (!fullscreenElement) return; | |
if (!isFullscreen) { | |
await fullscreenElement.requestFullscreen(); | |
} else { | |
await document.exitFullscreen(); | |
} | |
}; | |
// Player control handlers | |
const handlePlayPause = () => { | |
setIsPlaying(!isPlaying); | |
}; | |
const handleMute = () => { | |
setIsMuted(!isMuted); | |
}; | |
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const newVolume = parseFloat(e.target.value); | |
setVolume(newVolume); | |
if (newVolume === 0) { | |
setIsMuted(true); | |
} else if (isMuted) { | |
setIsMuted(false); | |
} | |
}; | |
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const newTime = parseFloat(e.target.value); | |
setProgress(newTime); | |
if (videoRef.current) { | |
videoRef.current.currentTime = newTime; | |
} | |
}; | |
// Direct progress bar click handler | |
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => { | |
if (!progressBarRef.current || !duration) return; | |
const rect = progressBarRef.current.getBoundingClientRect(); | |
const clickPosition = (e.clientX - rect.left) / rect.width; | |
const newTime = duration * clickPosition; | |
if (videoRef.current) { | |
videoRef.current.currentTime = newTime; | |
setProgress(newTime); | |
} | |
}; | |
// Progress bar hover handler for time preview | |
const handleProgressBarHover = (e: React.MouseEvent<HTMLDivElement>) => { | |
if (!progressBarRef.current || !duration) return; | |
const rect = progressBarRef.current.getBoundingClientRect(); | |
const hoverPosition = (e.clientX - rect.left) / rect.width; | |
const hoverTimeValue = duration * hoverPosition; | |
setHoverTime(hoverTimeValue); | |
setHoverPosition({ x: e.clientX, y: rect.top }); | |
}; | |
const handleProgressBarLeave = () => { | |
setHoverTime(null); | |
setHoverPosition(null); | |
}; | |
// Use the imported formatTime function with a fallback | |
const formatTimeDisplay = formatTime || formatTimeBackup; | |
const skipForward = () => { | |
if (videoRef.current) { | |
videoRef.current.currentTime = Math.min( | |
videoRef.current.duration, | |
videoRef.current.currentTime + 10 | |
); | |
} | |
}; | |
const skipBackward = () => { | |
if (videoRef.current) { | |
videoRef.current.currentTime = Math.max( | |
0, | |
videoRef.current.currentTime - 10 | |
); | |
} | |
}; | |
const toggleKeyboardControls = () => { | |
setShowKeyboardControls(prev => !prev); | |
setShowControls(true); | |
}; | |
return ( | |
<div | |
className="w-full h-full overflow-hidden bg-black" | |
ref={playerContainerRef} | |
onMouseMove={handleMouseMove} | |
> | |
{/* Content rating overlay - only shown briefly */} | |
{contentRating && showRating && ( | |
<div className="absolute top-16 left-6 z-40 bg-black/60 backdrop-blur-sm px-4 py-2 rounded text-white flex items-center gap-2 animate-fade-in"> | |
<div className="text-lg font-bold border px-2 py-0.5"> | |
{contentRating.rating} | |
</div> | |
<span className='font-extrabold text-2xl text-primary'>|</span> | |
<div className="text-sm"> | |
{contentRating.description} | |
</div> | |
</div> | |
)} | |
<video | |
ref={videoRef} | |
src={url} | |
className="w-full h-full object-contain" | |
poster={poster} | |
onClick={handlePlayPause} | |
playsInline | |
/> | |
{/* Custom overlay from parent components */} | |
{customOverlay} | |
{/* Controls overlay - visible based on state */} | |
<div | |
className={`absolute inset-0 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none' | |
}`} | |
> | |
{/* Top bar */} | |
<div className="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/80 to-transparent z-10"> | |
<div className="flex justify-between items-center"> | |
<button | |
onClick={onClose} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
<ArrowLeft size={24} /> | |
</button> | |
{!hideTitleInPlayer && ( | |
<h2 className="text-white font-medium text-lg hidden sm:block"> | |
{title} | |
</h2> | |
)} | |
<button | |
onClick={onClose} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
<X size={24} /> | |
</button> | |
</div> | |
</div> | |
{/* Center controls */} | |
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |
{/* Skip backward 10s */} | |
<button | |
onClick={skipBackward} | |
className="z-10 relative pointer-events-auto text-white hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-black/30 mx-4" | |
> | |
<Rewind size={32} /> | |
</button> | |
{/* Play/Pause button */} | |
<button | |
onClick={handlePlayPause} | |
className="text-white bg-black/30 backdrop-blur-sm p-4 rounded-full hover:bg-white/20 transition-all pointer-events-auto relative w-20 h-20 flex items-center justify-center" | |
> | |
{isPlaying ? ( | |
<Pause size={40} /> | |
) : ( | |
<Play size={40} /> | |
)} | |
</button> | |
{/* Skip forward 10s */} | |
<button | |
onClick={skipForward} | |
className="z-10 relative pointer-events-auto text-white hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-black/30 mx-4" | |
> | |
<FastForward size={32} /> | |
</button> | |
</div> | |
{/* Bottom controls */} | |
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent"> | |
{/* Progress bar */} | |
<div className="mb-3 relative"> | |
<div | |
ref={progressBarRef} | |
className="relative w-full h-2 bg-white/30 rounded-full group cursor-pointer" | |
onClick={handleProgressBarClick} | |
onMouseMove={handleProgressBarHover} | |
onMouseLeave={handleProgressBarLeave} | |
> | |
{/* Buffered progress */} | |
<div | |
className="absolute h-full bg-white/50 rounded-full" | |
style={{ width: `${(buffered / duration) * 100}%` }} | |
></div> | |
{/* Played progress */} | |
<div | |
className="absolute h-full bg-primary rounded-full" | |
style={{ width: `${(progress / duration) * 100}%` }} | |
> | |
{/* Thumb */} | |
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-primary rounded-full scale-0 group-hover:scale-100 transition-transform"></div> | |
</div> | |
{/* Time preview tooltip */} | |
{hoverTime !== null && hoverPosition && ( | |
<div | |
className="absolute -top-8 bg-black/80 px-2 py-1 rounded text-white text-xs transform -translate-x-1/2 pointer-events-none" | |
style={{ left: `${(hoverTime / duration) * 100}%` }} | |
> | |
{formatTimeDisplay(hoverTime)} | |
</div> | |
)} | |
{/* Invisible range input for seeking */} | |
<input | |
type="range" | |
min={0} | |
max={duration || 100} | |
value={progress} | |
onChange={handleProgressChange} | |
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" | |
style={{ padding: 0, margin: 0 }} | |
/> | |
</div> | |
<div className="flex justify-between text-xs text-white mt-1"> | |
<span>{formatTimeDisplay(progress)}</span> | |
<span>{formatTimeDisplay(duration)}</span> | |
</div> | |
</div> | |
{/* Controls row */} | |
<div className="flex justify-between items-center"> | |
<div className="flex items-center space-x-4"> | |
<button | |
onClick={handlePlayPause} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
{isPlaying ? <Pause size={24} /> : <Play size={24} />} | |
</button> | |
<div className="flex items-center relative group"> | |
<button | |
onClick={handleMute} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
{isMuted || volume === 0 ? <VolumeX size={24} /> : <Volume2 size={24} />} | |
</button> | |
<div className="hidden group-hover:block w-20 ml-2"> | |
<input | |
type="range" | |
min={0} | |
max={1} | |
step={0.1} | |
value={volume} | |
onChange={handleVolumeChange} | |
className="w-full h-1 bg-gray-700/50 appearance-none rounded cursor-pointer accent-primary" | |
/> | |
</div> | |
</div> | |
<button | |
onClick={toggleKeyboardControls} | |
className="text-white hover:text-gray-300 transition-colors" | |
title="Show keyboard shortcuts" | |
> | |
<Keyboard size={20} /> | |
</button> | |
</div> | |
<div className="flex items-center space-x-4"> | |
{!hideTitleInPlayer && title && ( | |
<div className="text-white text-sm hidden sm:block"> | |
<span>{title}</span> | |
</div> | |
)} | |
<button | |
onClick={toggleFullscreen} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
{isFullscreen ? <Minimize size={24} /> : <Maximize size={24} />} | |
</button> | |
{showNextButton && ( | |
<button | |
onClick={onVideoEnded} | |
className="text-white hover:text-gray-300 transition-colors" | |
> | |
<SkipForward size={24} /> | |
</button> | |
)} | |
</div> | |
</div> | |
</div> | |
</div> | |
{/* Keyboard controls dialog - shown only when requested */} | |
{showKeyboardControls && ( | |
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50" onClick={() => setShowKeyboardControls(false)}> | |
<div className="bg-gray-900/90 border border-gray-700 rounded-lg max-w-md w-full p-6" onClick={e => e.stopPropagation()}> | |
<div className="flex justify-between items-center mb-4"> | |
<h3 className="text-xl font-bold text-white">Keyboard Controls</h3> | |
<button onClick={() => setShowKeyboardControls(false)} className="text-gray-400 hover:text-white"> | |
<X size={20} /> | |
</button> | |
</div> | |
<div className="grid grid-cols-2 gap-y-3 text-sm"> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">Space</kbd> or <kbd className="px-2 py-1 bg-gray-800 rounded mx-2">K</kbd> | |
</div> | |
<div>Play/Pause</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">←</kbd> | |
</div> | |
<div>Rewind 10 seconds</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">→</kbd> | |
</div> | |
<div>Forward 10 seconds</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">M</kbd> | |
</div> | |
<div>Mute/Unmute</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">F</kbd> | |
</div> | |
<div>Fullscreen</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">Esc</kbd> | |
</div> | |
<div>Exit fullscreen/Close player</div> | |
<div className="flex items-center"> | |
<kbd className="px-2 py-1 bg-gray-800 rounded mr-2">?</kbd> | |
</div> | |
<div>Show/hide this menu</div> | |
</div> | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
export default VideoPlayer; | |