Spaces:
Running
Running
import { useState, useEffect, useCallback, useRef } from "react"; | |
import GlassContainer from "./GlassContainer"; | |
import GlassButton from "./GlassButton"; | |
import { GLASS_EFFECTS } from "../constants"; | |
interface VideoScrubberProps { | |
videoRef: React.RefObject<HTMLVideoElement | null>; | |
isVisible: boolean; | |
} | |
export default function VideoScrubber({ videoRef, isVisible }: VideoScrubberProps) { | |
const [currentTime, setCurrentTime] = useState(0); | |
const [duration, setDuration] = useState(0); | |
const [isDragging, setIsDragging] = useState(false); | |
const [isHovered, setIsHovered] = useState(false); | |
const [isPlaying, setIsPlaying] = useState(false); | |
const scrubberRef = useRef<HTMLInputElement>(null); | |
const formatTime = useCallback((seconds: number) => { | |
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 updateProgress = useCallback(() => { | |
const video = videoRef.current; | |
if (video && !isDragging) { | |
const time = isFinite(video.currentTime) ? video.currentTime : 0; | |
const dur = isFinite(video.duration) ? video.duration : 0; | |
setCurrentTime(time); | |
setDuration(dur); | |
setIsPlaying(!video.paused); | |
} | |
}, [videoRef, isDragging]); | |
const togglePlayPause = useCallback(() => { | |
const video = videoRef.current; | |
if (video) { | |
if (video.paused) { | |
video.play(); | |
} else { | |
video.pause(); | |
} | |
} | |
}, [videoRef]); | |
const handleSeek = useCallback((newTime: number) => { | |
const video = videoRef.current; | |
if (video && !isNaN(newTime)) { | |
video.currentTime = newTime; | |
setCurrentTime(newTime); | |
} | |
}, [videoRef]); | |
const handleScrubberChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |
const newTime = parseFloat(event.target.value); | |
handleSeek(newTime); | |
}, [handleSeek]); | |
const handleMouseDown = useCallback(() => { | |
setIsDragging(true); | |
}, []); | |
const handleMouseUp = useCallback(() => { | |
setIsDragging(false); | |
}, []); | |
useEffect(() => { | |
const video = videoRef.current; | |
if (!video) return; | |
const handleLoadedMetadata = () => { | |
const dur = isFinite(video.duration) ? video.duration : 0; | |
const time = isFinite(video.currentTime) ? video.currentTime : 0; | |
setDuration(dur); | |
setCurrentTime(time); | |
}; | |
const handleTimeUpdate = () => { | |
updateProgress(); | |
}; | |
const handlePlay = () => setIsPlaying(true); | |
const handlePause = () => setIsPlaying(false); | |
video.addEventListener('loadedmetadata', handleLoadedMetadata); | |
video.addEventListener('timeupdate', handleTimeUpdate); | |
video.addEventListener('play', handlePlay); | |
video.addEventListener('pause', handlePause); | |
// Update immediately if metadata is already loaded | |
if (video.readyState >= 1) { | |
handleLoadedMetadata(); | |
} | |
return () => { | |
video.removeEventListener('loadedmetadata', handleLoadedMetadata); | |
video.removeEventListener('timeupdate', handleTimeUpdate); | |
video.removeEventListener('play', handlePlay); | |
video.removeEventListener('pause', handlePause); | |
}; | |
}, [videoRef, updateProgress]); | |
useEffect(() => { | |
if (isDragging) { | |
document.addEventListener('mouseup', handleMouseUp); | |
return () => { | |
document.removeEventListener('mouseup', handleMouseUp); | |
}; | |
} | |
}, [isDragging, handleMouseUp]); | |
if (!isVisible) { | |
return null; | |
} | |
const progressPercentage = duration > 0 && isFinite(duration) && isFinite(currentTime) | |
? Math.min((currentTime / duration) * 100, 100) | |
: 0; | |
return ( | |
<div | |
className={`absolute bottom-4 left-4 right-4 z-[200] transition-opacity duration-300 ${ | |
isHovered || isDragging ? 'opacity-100' : 'opacity-90' | |
}`} | |
onMouseEnter={() => setIsHovered(true)} | |
onMouseLeave={() => setIsHovered(false)} | |
style={{ pointerEvents: 'auto' }} | |
> | |
<GlassContainer | |
bgColor={GLASS_EFFECTS.COLORS.DEFAULT_BG} | |
className="rounded-lg px-4 py-3 shadow-lg" | |
> | |
<div className="flex items-center space-x-4"> | |
{/* Play/Pause Button */} | |
<GlassButton | |
onClick={togglePlayPause} | |
className="w-10 h-10 rounded-full flex items-center justify-center p-0" | |
aria-label={isPlaying ? "Pause video" : "Play video"} | |
> | |
{isPlaying ? ( | |
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20"> | |
<path fillRule="evenodd" d="M6 4a1 1 0 011 1v10a1 1 0 11-2 0V5a1 1 0 011-1zM14 4a1 1 0 011 1v10a1 1 0 11-2 0V5a1 1 0 011-1z" clipRule="evenodd" /> | |
</svg> | |
) : ( | |
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 20 20"> | |
<path fillRule="evenodd" d="M6.267 3.455a.5.5 0 01.531-.024L15.5 8.5a.5.5 0 010 .872l-8.702 5.069a.5.5 0 01-.765-.436V3.455z" clipRule="evenodd" /> | |
</svg> | |
)} | |
</GlassButton> | |
{/* Current Time */} | |
<div className="text-white text-sm font-mono min-w-[3.5rem]"> | |
{formatTime(currentTime)} | |
</div> | |
{/* Timeline Scrubber */} | |
<div className="flex-1 relative px-2"> | |
<input | |
ref={scrubberRef} | |
type="range" | |
min={0} | |
max={duration || 100} | |
step={0.1} | |
value={currentTime} | |
onChange={handleScrubberChange} | |
onMouseDown={handleMouseDown} | |
className="w-full h-1.5 bg-transparent rounded-lg appearance-none cursor-pointer | |
[&::-webkit-slider-track]:bg-gray-600/50 | |
[&::-webkit-slider-track]:rounded-lg | |
[&::-webkit-slider-track]:h-1.5 | |
[&::-webkit-slider-thumb]:appearance-none | |
[&::-webkit-slider-thumb]:w-4 | |
[&::-webkit-slider-thumb]:h-4 | |
[&::-webkit-slider-thumb]:rounded-full | |
[&::-webkit-slider-thumb]:bg-white | |
[&::-webkit-slider-thumb]:shadow-lg | |
[&::-webkit-slider-thumb]:cursor-pointer | |
[&::-webkit-slider-thumb]:border-2 | |
[&::-webkit-slider-thumb]:border-blue-500 | |
[&::-webkit-slider-thumb]:transition-transform | |
[&::-moz-range-track]:bg-gray-600/50 | |
[&::-moz-range-track]:rounded-lg | |
[&::-moz-range-track]:h-1.5 | |
[&::-moz-range-track]:border-0 | |
[&::-moz-range-thumb]:w-4 | |
[&::-moz-range-thumb]:h-4 | |
[&::-moz-range-thumb]:rounded-full | |
[&::-moz-range-thumb]:bg-white | |
[&::-moz-range-thumb]:border-2 | |
[&::-moz-range-thumb]:border-blue-500 | |
[&::-moz-range-thumb]:cursor-pointer | |
[&::-moz-range-thumb]:transition-transform | |
hover:[&::-webkit-slider-thumb]:scale-110 | |
hover:[&::-moz-range-thumb]:scale-110" | |
style={{ | |
background: `linear-gradient(to right, | |
#3b82f6 0%, | |
#3b82f6 ${progressPercentage}%, | |
rgba(75, 85, 99, 0.3) ${progressPercentage}%, | |
rgba(75, 85, 99, 0.3) 100%)` | |
}} | |
/> | |
</div> | |
{/* Duration */} | |
<div className="text-white text-sm font-mono min-w-[3.5rem] text-right"> | |
{formatTime(duration)} | |
</div> | |
</div> | |
</GlassContainer> | |
</div> | |
); | |
} |