'use client'; import { ExternalLink, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; /** * Hero video player tuned for the stubborn-autoplay cases we hit in * the field (Chrome with prefers-reduced-motion, data saver, etc.). * * Three failure modes we defend against: * * 1. Browser allows muted autoplay — the happy path. Element loads, * starts playing, the `play` event flips `playing` to true and * hides the overlay. * * 2. Browser blocks autoplay but allows playback on user gesture — * the play overlay sits over the poster, the user clicks, we * call `.load()` first to reset the resource-selection state and * then `.play()` with the user gesture in scope. * * 3. Browser refuses to play even after the gesture — we catch the * promise rejection and surface a small "open video in a new tab" * link so the visitor isn't completely stuck. * * Controls: a deliberately subtle bottom bar (play/pause, elapsed time, * a seek slider, mute) that stays out of the way — it only fades in on * hover (desktop) or tap (touch) and auto-hides ~2.8s after the last * interaction while playing; while paused it stays put. The seek slider * is a real (keyboard + drag + touch, accessible) * laid invisibly over a custom-drawn track so the look matches the rest * of the page rather than the chrome of a native control bar. * * Source order: MP4 only. We previously offered WebM (VP9) first as a * size win, but Chrome will pick WebM if listed first, and if that * decode fails it does NOT fall back to MP4 — it just sits unloaded. */ const CONTROLS_HIDE_MS = 2800; function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${String(s).padStart(2, '0')}`; } export function HeroVideo() { const videoRef = useRef(null); const hideTimer = useRef | null>(null); const [muted, setMuted] = useState(true); const [playing, setPlaying] = useState(false); const [playFailed, setPlayFailed] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [controlsVisible, setControlsVisible] = useState(false); useEffect(() => { const v = videoRef.current; if (!v) return; const onPlay = () => { setPlaying(true); setPlayFailed(false); }; const onPause = () => setPlaying(false); const onTime = () => setCurrentTime(v.currentTime); const onMeta = () => setDuration(Number.isFinite(v.duration) ? v.duration : 0); v.addEventListener('play', onPlay); v.addEventListener('pause', onPause); v.addEventListener('timeupdate', onTime); v.addEventListener('loadedmetadata', onMeta); v.addEventListener('durationchange', onMeta); // Best-effort autoplay attempt — silently fail on browsers that // block it; the overlay is the user's escape hatch. v.play().catch(() => undefined); return () => { v.removeEventListener('play', onPlay); v.removeEventListener('pause', onPause); v.removeEventListener('timeupdate', onTime); v.removeEventListener('loadedmetadata', onMeta); v.removeEventListener('durationchange', onMeta); }; }, []); // Clear any pending hide timer on unmount. useEffect(() => () => { if (hideTimer.current) clearTimeout(hideTimer.current); }, []); const scheduleHide = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); hideTimer.current = setTimeout(() => setControlsVisible(false), CONTROLS_HIDE_MS); }, []); const revealControls = useCallback(() => { setControlsVisible(true); scheduleHide(); }, [scheduleHide]); const hideControlsNow = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); setControlsVisible(false); }, []); const togglePlay = useCallback(async () => { const v = videoRef.current; if (!v) return; if (v.paused) { setPlayFailed(false); try { // .load() resets the media element's resource selection — required // when an earlier autoplay attempt was blocked before the source // ever fetched. Without it, .play() on a stuck element no-ops. v.load(); await v.play(); } catch { setPlayFailed(true); } } else { v.pause(); } }, []); // Click anywhere on the frame toggles playback (YouTube/Vimeo pattern) and // reveals the controls — on touch, where there's no hover, this is how the // bar surfaces. const onVideoClick = useCallback(() => { revealControls(); void togglePlay(); }, [revealControls, togglePlay]); const toggleMute = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const v = videoRef.current; if (!v) return; const next = !v.muted; v.muted = next; setMuted(next); // Restart from frame 0 whenever the audio state toggles, so the narration // and the silent loop both line up with the animation from the top. v.currentTime = 0; if (v.paused) v.play().catch(() => undefined); }, []); const onSeek = useCallback((e: React.ChangeEvent) => { const v = videoRef.current; if (!v) return; const t = Number(e.currentTarget.value); if (!Number.isFinite(t)) return; v.currentTime = t; setCurrentTime(t); }, []); const pct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; // Bar shows on hover/tap; while paused it stays up so the scrubber is reachable. const barShown = controlsVisible || !playing; return (
{ if (playing) hideControlsNow(); }} > {/* PLAY overlay — visible while paused. Full-frame so clicking anywhere starts playback. */} {!playing && ( )} {/* Fallback escape hatch — surfaces only if .play() rejects even after a user gesture (extension sandbox, hardware-decoder failures, etc.). */} {playFailed && !playing && ( your browser blocked playback. open the video directly )} {/* Controls bar — subtle, reveals on hover/tap, auto-hides while playing. pointer-events-none when hidden so it never swallows a frame click. */}
{formatTime(currentTime)} / {formatTime(duration)} {/* Seek: custom-drawn rail + fill + thumb, with a transparent native range on top carrying all the interaction (drag, touch, keyboard). */}
); }