Spaces:
Sleeping
Sleeping
| import { useEffect, useRef } from 'react'; | |
| /** | |
| * FadingVideo Component | |
| * Custom requestAnimationFrame crossfade logic for looping background videos. | |
| * Opacity fades out at 0.55s before video ends and fades back in on loop start. | |
| */ | |
| export default function FadingVideo({ src, className, style }) { | |
| const videoRef = useRef(null); | |
| const rafIdRef = useRef(null); | |
| const fadingOutRef = useRef(false); | |
| const FADE_MS = 500; | |
| const FADE_OUT_LEAD = 0.55; // seconds before end to start fading out | |
| const fadeTo = (targetOpacity, duration) => { | |
| if (!videoRef.current) return; | |
| const video = videoRef.current; | |
| // Cancel any ongoing animation frame | |
| if (rafIdRef.current) { | |
| cancelAnimationFrame(rafIdRef.current); | |
| } | |
| const currentOpacity = parseFloat(video.style.opacity) || 0; | |
| const opacityDifference = targetOpacity - currentOpacity; | |
| if (opacityDifference === 0) return; | |
| const startTime = performance.now(); | |
| const animate = (currentTime) => { | |
| const elapsed = currentTime - startTime; | |
| const progress = Math.min(elapsed / duration, 1); | |
| // Calculate next opacity | |
| const nextOpacity = currentOpacity + opacityDifference * progress; | |
| if (videoRef.current) { | |
| videoRef.current.style.opacity = nextOpacity.toString(); | |
| } | |
| if (progress < 1) { | |
| rafIdRef.current = requestAnimationFrame(animate); | |
| } | |
| }; | |
| rafIdRef.current = requestAnimationFrame(animate); | |
| }; | |
| useEffect(() => { | |
| const video = videoRef.current; | |
| if (!video) return; | |
| // Initially transparent | |
| video.style.opacity = '0'; | |
| const handleLoadedData = () => { | |
| video.style.opacity = '0'; | |
| video.play().catch(() => {}); | |
| fadeTo(1, FADE_MS); | |
| }; | |
| const handleTimeUpdate = () => { | |
| if (!video.duration) return; | |
| const remainingTime = video.duration - video.currentTime; | |
| // Start fade out at 0.55 seconds before end | |
| if (!fadingOutRef.current && remainingTime <= FADE_OUT_LEAD && remainingTime > 0) { | |
| fadingOutRef.current = true; | |
| fadeTo(0, FADE_MS); | |
| } | |
| }; | |
| const handleEnded = () => { | |
| video.style.opacity = '0'; | |
| setTimeout(() => { | |
| if (!videoRef.current) return; | |
| videoRef.current.currentTime = 0; | |
| videoRef.current.play().catch(() => {}); | |
| fadingOutRef.current = false; | |
| fadeTo(1, FADE_MS); | |
| }, 100); | |
| }; | |
| // Listeners | |
| video.addEventListener('loadeddata', handleLoadedData); | |
| video.addEventListener('timeupdate', handleTimeUpdate); | |
| video.addEventListener('ended', handleEnded); | |
| // If already loaded/ready | |
| if (video.readyState >= 2) { | |
| handleLoadedData(); | |
| } | |
| return () => { | |
| if (rafIdRef.current) { | |
| cancelAnimationFrame(rafIdRef.current); | |
| } | |
| video.removeEventListener('loadeddata', handleLoadedData); | |
| video.removeEventListener('timeupdate', handleTimeUpdate); | |
| video.removeEventListener('ended', handleEnded); | |
| }; | |
| }, [src]); | |
| return ( | |
| <video | |
| ref={videoRef} | |
| src={src} | |
| className={className} | |
| style={{ | |
| ...style, | |
| transition: 'none', // Ensure NO CSS transitions interfere with our rAF fades | |
| }} | |
| autoPlay | |
| muted | |
| playsInline | |
| preload="auto" | |
| /> | |
| ); | |
| } | |