diff --git a/apps/web/components/hero-video.tsx b/apps/web/components/hero-video.tsx index 378c448..a0571e7 100644 --- a/apps/web/components/hero-video.tsx +++ b/apps/web/components/hero-video.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ExternalLink, Play, Volume2, VolumeX } from 'lucide-react'; +import { ExternalLink, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; /** @@ -15,30 +15,44 @@ import { useCallback, useEffect, useRef, useState } from 'react'; * * 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 - * (some Chrome builds park the element at networkState=2/ - * readyState=0 forever if the original autoplay was blocked - * before the source ever fetched) and then `.play()` with the - * user gesture in scope. Plays. + * 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 — extension- - * sandboxed contexts, hardware-decoder failures, etc. We catch - * the promise rejection, surface a small "open video in a new - * tab" link so the visitor isn't completely stuck. + * 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 in an - * unloaded state. Single MP4 source (Main profile / yuv420p / TV- - * range / faststart) plays everywhere and the file is 2.6 MB which is - * close enough to the WebM that the extra HTTP source-juggling isn't - * worth the failure surface. + * 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; @@ -48,17 +62,45 @@ export function HeroVideo() { 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; @@ -78,6 +120,14 @@ export function HeroVideo() { } }, []); + // 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; @@ -85,18 +135,34 @@ export function HeroVideo() { const next = !v.muted; v.muted = next; setMuted(next); - // Restart from frame 0 whenever the audio state toggles. Visitors who - // unmute want to hear the opening, not whatever moment the video was - // already at; visitors who re-mute also expect a clean restart so the - // animation lines up with the silent loop again. + // 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); - } + 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 (YouTube / Vimeo pattern). */} + anywhere starts playback. */} {!playing && ( )} - {/* Fallback escape hatch — surfaces only if .play() rejects even - after a user gesture (extension sandbox, hardware-decoder - failures, etc.). One-line link to the raw MP4 so the visitor - isn't trapped staring at a poster forever. */} + {/* Fallback escape hatch — surfaces only if .play() rejects even after a + user gesture (extension sandbox, hardware-decoder failures, etc.). */} {playFailed && !playing && ( )} - {/* Mute toggle — always visible, top of z-stack. */} - - +
+ + + + {formatTime(currentTime)} / {formatTime(duration)} + + + {/* Seek: custom-drawn rail + fill + thumb, with a transparent native + range on top carrying all the interaction (drag, touch, keyboard). */} +
+
+
+
+
+ +
+ + +
+
+
); }