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 && (