Spaces:
Sleeping
Sleeping
| 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; | |