// FadingVideo — custom rAF-driven opacity crossfade. No CSS transitions. // loop attribute is OFF; manual loop via `ended`. const { useEffect, useRef } = React; const FADE_MS = 500; const FADE_OUT_LEAD = 0.55; // seconds function FadingVideo({ src, poster, className, style }) { const ref = useRef(null); const rafRef = useRef(0); const fadingOutRef = useRef(false); useEffect(() => { const v = ref.current; if (!v) return; v.style.opacity = '0'; v.style.transition = 'none'; const fadeTo = (target, duration = FADE_MS) => { if (rafRef.current) cancelAnimationFrame(rafRef.current); const startOpacity = parseFloat(v.style.opacity || '0'); const startTime = performance.now(); const tick = (now) => { const elapsed = now - startTime; const k = Math.min(1, elapsed / duration); const next = startOpacity + (target - startOpacity) * k; v.style.opacity = String(next); if (k < 1) { rafRef.current = requestAnimationFrame(tick); } else { v.style.opacity = String(target); rafRef.current = 0; } }; rafRef.current = requestAnimationFrame(tick); }; const onLoadedData = () => { v.style.opacity = '0'; const playPromise = v.play(); if (playPromise && playPromise.catch) playPromise.catch(() => {}); fadeTo(1, FADE_MS); }; // Lazy-load: only start downloading & playing when the video scrolls into view. // Saves big on initial page load (the hero video still loads early because it's on // screen from the start, but the capabilities video waits until user scrolls). let observer = null; let loaded = false; const startPlayback = () => { if (loaded) return; loaded = true; v.preload = 'auto'; v.load(); // Browser will fire 'loadeddata' once the metadata is ready }; const onTimeUpdate = () => { if (fadingOutRef.current) return; const remaining = (v.duration || 0) - (v.currentTime || 0); if (remaining > 0 && remaining <= FADE_OUT_LEAD) { fadingOutRef.current = true; fadeTo(0, FADE_MS); } }; const onEnded = () => { v.style.opacity = '0'; setTimeout(() => { try { v.currentTime = 0; const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (_) {} fadingOutRef.current = false; fadeTo(1, FADE_MS); }, 100); }; v.addEventListener('loadeddata', onLoadedData); v.addEventListener('timeupdate', onTimeUpdate); v.addEventListener('ended', onEnded); // Defer loading until in view (uses IntersectionObserver). // Hero video usually starts in-view so this triggers immediately. if ('IntersectionObserver' in window) { observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { startPlayback(); observer.disconnect(); observer = null; } }); }, { rootMargin: '200px' }); observer.observe(v); } else { startPlayback(); } // In case loadeddata already fired before listener attached if (v.readyState >= 2) onLoadedData(); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); if (observer) observer.disconnect(); v.removeEventListener('loadeddata', onLoadedData); v.removeEventListener('timeupdate', onTimeUpdate); v.removeEventListener('ended', onEnded); }; }, [src]); return (