75 lines
2.7 KiB
TypeScript
75 lines
2.7 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { Volume2, VolumeX } from 'lucide-react';
|
||
|
|
import { useCallback, useRef, useState } from 'react';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hero video player with a frosted-glass mute toggle.
|
||
|
|
*
|
||
|
|
* Why not the native `controls` attribute: it pulls in a chrome bar that
|
||
|
|
* fights the section's design vocabulary. We need exactly one control —
|
||
|
|
* mute / unmute — so we render just that, styled to match the rest of
|
||
|
|
* the brand (frosted indigo-bordered pill, bottom-right of the video).
|
||
|
|
*
|
||
|
|
* Default state is MUTED because every modern browser blocks autoplay
|
||
|
|
* for unmuted media. The toggle is the user's explicit affordance to
|
||
|
|
* turn the voice-over on; we keep the `muted` attribute on the element
|
||
|
|
* as the initial value so the autoplay still fires.
|
||
|
|
*
|
||
|
|
* On unmute we also call `.play()` defensively — some browsers (mobile
|
||
|
|
* Safari especially) pause the element when the muted property flips,
|
||
|
|
* even mid-playback. The promise is swallowed because a rejection here
|
||
|
|
* just means the user has to click again, which the toggle already
|
||
|
|
* affords.
|
||
|
|
*/
|
||
|
|
export function HeroVideo() {
|
||
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
|
const [muted, setMuted] = useState(true);
|
||
|
|
|
||
|
|
const toggleMute = useCallback(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
const next = !v.muted;
|
||
|
|
v.muted = next;
|
||
|
|
setMuted(next);
|
||
|
|
if (!next && v.paused) {
|
||
|
|
// Best-effort: keep playback running after the user enables sound.
|
||
|
|
v.play().catch(() => undefined);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<video
|
||
|
|
ref={videoRef}
|
||
|
|
autoPlay
|
||
|
|
muted
|
||
|
|
loop
|
||
|
|
playsInline
|
||
|
|
preload="auto"
|
||
|
|
poster="/videos/hero-poster.jpg"
|
||
|
|
className="size-full object-cover"
|
||
|
|
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
|
||
|
|
>
|
||
|
|
<source src="/videos/hero.webm" type="video/webm" />
|
||
|
|
<source src="/videos/hero.mp4" type="video/mp4" />
|
||
|
|
</video>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={toggleMute}
|
||
|
|
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
||
|
|
aria-pressed={!muted}
|
||
|
|
className="absolute bottom-4 right-4 z-10 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={{
|
||
|
|
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={18} /> : <Volume2 size={18} />}
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|