feat(video): play-overlay for blocked autoplay + click-to-play
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
Owner reported "video läuft nicht, sehe nur foto" — classic blocked- autoplay on browsers with prefers-reduced-motion / data-saver / strict autoplay policies. The poster sat there forever and the visitor thought the page was broken because the only control was a tiny mute pill they didn't realise would also start playback. Fixes: - Tracks `playing` state via the video element's own play/pause events so React knows whether the browser actually granted autoplay. - Renders a large centre PLAY button overlay whenever the video is paused. The button covers the full frame (universal YouTube / Vimeo pattern: click anywhere on the video to play); the inner indigo circle with the triangle is the visual affordance, with hover scale for tactile feedback. - Wires onClick directly on the <video> element too so the click- anywhere-to-play works whether or not the overlay happens to be up. - Mute toggle now calls e.stopPropagation so tapping it doesn't accidentally trigger play/pause via the video's onClick handler. - Best-effort .play() call in the mount effect, with the rejection silently swallowed — failure just means the user has to click play themselves, which the overlay already affords. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
438ce3cfbc
commit
b464b5640f
@ -1,39 +1,81 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Volume2, VolumeX } from 'lucide-react';
|
import { Play, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hero video player with a frosted-glass mute toggle.
|
* Hero video player with two controls:
|
||||||
*
|
*
|
||||||
* Why not the native `controls` attribute: it pulls in a chrome bar that
|
* 1. Big PLAY overlay shown while the video is paused. Browsers
|
||||||
* fights the section's design vocabulary. We need exactly one control —
|
* block autoplay for users with `prefers-reduced-motion`, on data
|
||||||
* mute / unmute — so we render just that, styled to match the rest of
|
* saver, or with strict autoplay policies — so the poster sits
|
||||||
* the brand (frosted indigo-bordered pill, bottom-right of the video).
|
* there forever and the visitor thinks the page is broken. The
|
||||||
|
* overlay gives them an explicit, unmissable affordance to start
|
||||||
|
* it. We also wire `onClick` on the <video> element itself so
|
||||||
|
* clicking anywhere on the frame plays / pauses, matching the
|
||||||
|
* universal YouTube / Vimeo pattern.
|
||||||
*
|
*
|
||||||
* Default state is MUTED because every modern browser blocks autoplay
|
* 2. Frosted mute toggle pinned bottom-right. Always visible so the
|
||||||
* for unmuted media. The toggle is the user's explicit affordance to
|
* visitor can flip the voice-over on whether they hit play
|
||||||
* turn the voice-over on; we keep the `muted` attribute on the element
|
* themselves or it autoplayed.
|
||||||
* as the initial value so the autoplay still fires.
|
|
||||||
*
|
*
|
||||||
* On unmute we also call `.play()` defensively — some browsers (mobile
|
* Initial state is muted (browser autoplay requires it). On mount we
|
||||||
* Safari especially) pause the element when the muted property flips,
|
* attempt `.play()` defensively — the promise resolves silently if
|
||||||
* even mid-playback. The promise is swallowed because a rejection here
|
* autoplay was granted, rejects silently if blocked. Either way the
|
||||||
* just means the user has to click again, which the toggle already
|
* `play` / `pause` event listeners keep our React state in sync with
|
||||||
|
* whatever the element is actually doing.
|
||||||
|
*
|
||||||
|
* On unmute we re-call `.play()` because mobile Safari pauses on a
|
||||||
|
* muted-property change mid-playback. Swallow the promise — failure
|
||||||
|
* just means the user can hit play again, which the overlay already
|
||||||
* affords.
|
* affords.
|
||||||
*/
|
*/
|
||||||
export function HeroVideo() {
|
export function HeroVideo() {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [muted, setMuted] = useState(true);
|
const [muted, setMuted] = useState(true);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
useEffect(() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
|
||||||
|
// Sync React state with whatever the element actually does.
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
v.addEventListener('play', onPlay);
|
||||||
|
v.addEventListener('pause', onPause);
|
||||||
|
|
||||||
|
// Best-effort autoplay. Promise rejection = user has to click play.
|
||||||
|
v.play().catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
v.removeEventListener('play', onPlay);
|
||||||
|
v.removeEventListener('pause', onPause);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
if (v.paused) {
|
||||||
|
v.play().catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
v.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMute = useCallback((e: React.MouseEvent) => {
|
||||||
|
// Stop propagation so clicking the mute pill doesn't also
|
||||||
|
// play/pause via the video's onClick handler.
|
||||||
|
e.stopPropagation();
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
const next = !v.muted;
|
const next = !v.muted;
|
||||||
v.muted = next;
|
v.muted = next;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
if (!next && v.paused) {
|
if (!next && v.paused) {
|
||||||
// Best-effort: keep playback running after the user enables sound.
|
// Keep playback running after the user enables sound — mobile
|
||||||
|
// Safari pauses on muted-property change mid-playback.
|
||||||
v.play().catch(() => undefined);
|
v.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -48,21 +90,56 @@ export function HeroVideo() {
|
|||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
poster="/videos/hero-poster.jpg"
|
poster="/videos/hero-poster.jpg"
|
||||||
className="size-full object-cover"
|
onClick={togglePlay}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<source src="/videos/hero.webm" type="video/webm" />
|
<source src="/videos/hero.webm" type="video/webm" />
|
||||||
<source src="/videos/hero.mp4" type="video/mp4" />
|
<source src="/videos/hero.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
{/* Centre PLAY button — shown only while paused. Covers the
|
||||||
|
whole video so clicking anywhere starts playback; the inner
|
||||||
|
circle is the visual affordance. */}
|
||||||
|
{!playing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label="Play video"
|
||||||
|
className="group absolute inset-0 z-10 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex size-20 items-center justify-center rounded-full border backdrop-blur transition-transform duration-200 ease-out group-hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
|
||||||
|
borderColor: 'var(--color-accent)',
|
||||||
|
boxShadow:
|
||||||
|
'0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* `translate-x-0.5` optically centres the play triangle —
|
||||||
|
its visual centre of mass is left of its bounding box. */}
|
||||||
|
<Play size={32} fill="currentColor" className="translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mute toggle — always visible, top of z-stack so it stays
|
||||||
|
clickable even when the play overlay is up. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
||||||
aria-pressed={!muted}
|
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]"
|
className="absolute bottom-4 right-4 z-20 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: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
backgroundColor:
|
||||||
|
'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
|
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user