feat(web): subtle hover/tap video controls (seek + play/pause) Add a discreet bottom control bar to the hero video — play/pause, elapsed time, a seek slider, and mute — that reveals on hover (desktop) or tap (touch) and auto-hides ~2.8s after the last interaction while playing; it stays visible while paused so the scrubber is reachable. The seek slider is a real <input type=range> (keyboard/drag/touch, accessible) laid invisibly over a custom rail+fill so the look matches the page. Autoplay/muted/loop, the centre play overlay, the play-failed fallback link and poster are unchanged; the always-on mute button is now folded into the bar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
parent
cf423de3d5
commit
21a5cf5762
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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';
|
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 —
|
* 2. Browser blocks autoplay but allows playback on user gesture —
|
||||||
* the play overlay sits over the poster, the user clicks, we
|
* the play overlay sits over the poster, the user clicks, we
|
||||||
* call `.load()` first to reset the resource-selection state
|
* call `.load()` first to reset the resource-selection state and
|
||||||
* (some Chrome builds park the element at networkState=2/
|
* then `.play()` with the user gesture in scope.
|
||||||
* readyState=0 forever if the original autoplay was blocked
|
|
||||||
* before the source ever fetched) and then `.play()` with the
|
|
||||||
* user gesture in scope. Plays.
|
|
||||||
*
|
*
|
||||||
* 3. Browser refuses to play even after the gesture — extension-
|
* 3. Browser refuses to play even after the gesture — we catch the
|
||||||
* sandboxed contexts, hardware-decoder failures, etc. We catch
|
* promise rejection and surface a small "open video in a new tab"
|
||||||
* the promise rejection, surface a small "open video in a new
|
* link so the visitor isn't completely stuck.
|
||||||
* 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 <input type=range> (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
|
* 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
|
* 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
|
* decode fails it does NOT fall back to MP4 — it just sits unloaded.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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() {
|
export function HeroVideo() {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [muted, setMuted] = useState(true);
|
const [muted, setMuted] = useState(true);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [playFailed, setPlayFailed] = useState(false);
|
const [playFailed, setPlayFailed] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@ -48,17 +62,45 @@ export function HeroVideo() {
|
|||||||
setPlayFailed(false);
|
setPlayFailed(false);
|
||||||
};
|
};
|
||||||
const onPause = () => setPlaying(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('play', onPlay);
|
||||||
v.addEventListener('pause', onPause);
|
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
|
// Best-effort autoplay attempt — silently fail on browsers that
|
||||||
// block it; the overlay is the user's escape hatch.
|
// block it; the overlay is the user's escape hatch.
|
||||||
v.play().catch(() => undefined);
|
v.play().catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener('play', onPlay);
|
v.removeEventListener('play', onPlay);
|
||||||
v.removeEventListener('pause', onPause);
|
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 togglePlay = useCallback(async () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
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) => {
|
const toggleMute = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@ -85,18 +135,34 @@ export function HeroVideo() {
|
|||||||
const next = !v.muted;
|
const next = !v.muted;
|
||||||
v.muted = next;
|
v.muted = next;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
// Restart from frame 0 whenever the audio state toggles. Visitors who
|
// Restart from frame 0 whenever the audio state toggles, so the narration
|
||||||
// unmute want to hear the opening, not whatever moment the video was
|
// and the silent loop both line up with the animation from the top.
|
||||||
// already at; visitors who re-mute also expect a clean restart so the
|
|
||||||
// animation lines up with the silent loop again.
|
|
||||||
v.currentTime = 0;
|
v.currentTime = 0;
|
||||||
if (v.paused) {
|
if (v.paused) v.play().catch(() => undefined);
|
||||||
v.play().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
onPointerEnter={revealControls}
|
||||||
|
onPointerMove={revealControls}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
if (playing) hideControlsNow();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
@ -105,7 +171,7 @@ export function HeroVideo() {
|
|||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
poster="/videos/hero-poster.jpg"
|
poster="/videos/hero-poster.jpg"
|
||||||
onClick={togglePlay}
|
onClick={onVideoClick}
|
||||||
className="size-full cursor-pointer object-cover"
|
className="size-full cursor-pointer object-cover"
|
||||||
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
|
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
|
||||||
>
|
>
|
||||||
@ -114,26 +180,23 @@ export function HeroVideo() {
|
|||||||
</video>
|
</video>
|
||||||
|
|
||||||
{/* PLAY overlay — visible while paused. Full-frame so clicking
|
{/* PLAY overlay — visible while paused. Full-frame so clicking
|
||||||
anywhere starts playback (YouTube / Vimeo pattern). */}
|
anywhere starts playback. */}
|
||||||
{!playing && (
|
{!playing && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={togglePlay}
|
onClick={onVideoClick}
|
||||||
aria-label="Play video"
|
aria-label="Play video"
|
||||||
className="group absolute inset-0 z-10 flex items-center justify-center"
|
className="group absolute inset-0 z-10 flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor: 'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
||||||
'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex size-20 items-center justify-center rounded-full border backdrop-blur transition-transform duration-200 ease-out group-hover:scale-110"
|
className="flex size-20 items-center justify-center rounded-full border backdrop-blur transition-transform duration-200 ease-out group-hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
|
||||||
'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
|
|
||||||
borderColor: 'var(--color-accent)',
|
borderColor: 'var(--color-accent)',
|
||||||
boxShadow:
|
boxShadow: '0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
|
||||||
'0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
|
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -143,10 +206,8 @@ export function HeroVideo() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback escape hatch — surfaces only if .play() rejects even
|
{/* Fallback escape hatch — surfaces only if .play() rejects even after a
|
||||||
after a user gesture (extension sandbox, hardware-decoder
|
user gesture (extension sandbox, hardware-decoder failures, etc.). */}
|
||||||
failures, etc.). One-line link to the raw MP4 so the visitor
|
|
||||||
isn't trapped staring at a poster forever. */}
|
|
||||||
{playFailed && !playing && (
|
{playFailed && !playing && (
|
||||||
<a
|
<a
|
||||||
href="/videos/hero.mp4"
|
href="/videos/hero.mp4"
|
||||||
@ -154,8 +215,7 @@ export function HeroVideo() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="absolute inset-x-0 bottom-20 z-20 mx-auto flex w-fit items-center gap-2 rounded-md border px-3 py-2 text-[12px] backdrop-blur hover:text-[--color-fg]"
|
className="absolute inset-x-0 bottom-20 z-20 mx-auto flex w-fit items-center gap-2 rounded-md border px-3 py-2 text-[12px] backdrop-blur hover:text-[--color-fg]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
|
||||||
'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
|
|
||||||
borderColor: 'var(--color-border-strong)',
|
borderColor: 'var(--color-border-strong)',
|
||||||
color: 'var(--color-fg-muted)',
|
color: 'var(--color-fg-muted)',
|
||||||
}}
|
}}
|
||||||
@ -165,22 +225,93 @@ export function HeroVideo() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mute toggle — always visible, top of z-stack. */}
|
{/* Controls bar — subtle, reveals on hover/tap, auto-hides while playing.
|
||||||
<button
|
pointer-events-none when hidden so it never swallows a frame click. */}
|
||||||
type="button"
|
<div
|
||||||
onClick={toggleMute}
|
className={`absolute inset-x-0 bottom-0 z-30 px-4 pb-3 pt-10 transition-[opacity,transform] duration-200 ease-out ${
|
||||||
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
barShown ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
|
||||||
aria-pressed={!muted}
|
}`}
|
||||||
className="absolute bottom-4 right-4 z-30 inline-flex size-10 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
background:
|
||||||
'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
'linear-gradient(to top, color-mix(in oklab, var(--color-bg) 72%, transparent), transparent)',
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{muted ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<button
|
||||||
</>
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
revealControls();
|
||||||
|
void togglePlay();
|
||||||
|
}}
|
||||||
|
aria-label={playing ? 'Pause video' : 'Play video'}
|
||||||
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<Pause size={16} fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Play size={16} fill="currentColor" className="translate-x-px" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="mono shrink-0 text-[11px] tabular-nums"
|
||||||
|
style={{ color: 'var(--color-fg-muted)' }}
|
||||||
|
>
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Seek: custom-drawn rail + fill + thumb, with a transparent native
|
||||||
|
range on top carrying all the interaction (drag, touch, keyboard). */}
|
||||||
|
<div className="group/seek relative flex h-4 flex-1 items-center">
|
||||||
|
<div
|
||||||
|
className="h-1 w-full overflow-hidden rounded-full"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklab, var(--color-fg) 22%, transparent)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute size-3 -translate-x-1/2 rounded-full opacity-0 shadow transition-opacity duration-150 group-hover/seek:opacity-100 group-focus-within/seek:opacity-100"
|
||||||
|
style={{ left: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step="any"
|
||||||
|
value={Math.min(currentTime, duration || 0)}
|
||||||
|
onChange={onSeek}
|
||||||
|
onFocus={revealControls}
|
||||||
|
aria-label="Seek video"
|
||||||
|
className="absolute inset-0 size-full cursor-pointer appearance-none bg-transparent opacity-0 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMute}
|
||||||
|
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
||||||
|
aria-pressed={!muted}
|
||||||
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user