|
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); |
|
|
|
|
|
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}`; |
|
}; |
|
|
|
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]); |
|
|
|
|
|
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]); |
|
|
|
|
|
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(); |
|
} |
|
}; |
|
|
|
|
|
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; |
|
} |
|
}; |
|
|
|
|
|
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); |
|
} |
|
}; |
|
|
|
|
|
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); |
|
}; |
|
|
|
|
|
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; |
|
|