web / frontend /src /components /VideoPlayer.tsx
Chandima Prabhath
Track bun.lockb with Git LFS
cc2caf9
raw
history blame
19.8 kB
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;