fix(video): drop WebM source + load()-before-play() + open-in-tab fallback
All checks were successful
Deploy to Production / deploy (push) Successful in 59s
All checks were successful
Deploy to Production / deploy (push) Successful in 59s
Owner: "wird nicht richtig gestream hab browser daten gelöscht aber kann [nicht]" — clearing the cache didn't help. Three things changed: 1. **Single MP4 source.** Chrome listed the WebM source first because we offered it first; on the owner's setup the VP9 decode appears to stall silently and Chrome does NOT fall back to MP4 — it parks the element at networkState=2/readyState=0 forever. Removing the WebM source forces Chrome onto the MP4 (Main profile / yuv420p / TV-range / faststart, 2.6 MB) which we've already verified plays correctly. 2. **.load() before .play() in togglePlay.** When the original autoplay was blocked before the source ever fetched, some Chrome builds leave the element in a "stuck unloaded" state where subsequent .play() calls inside a user gesture also no-op. Calling .load() first resets the resource-selection algorithm, then .play() fetches and plays. 3. **playFailed escape hatch.** If .play() still rejects even after .load() + user gesture (extension sandbox, hardware decoder failure), surface a small "your browser blocked playback — open the video directly" link to the raw MP4. The visitor isn't trapped staring at a poster. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b464b5640f
commit
05746e13e6
@ -1,72 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import { ExternalLink, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hero video player with two controls:
|
||||
* Hero video player tuned for the stubborn-autoplay cases we hit in
|
||||
* the field (Chrome with prefers-reduced-motion, data saver, etc.).
|
||||
*
|
||||
* 1. Big PLAY overlay shown while the video is paused. Browsers
|
||||
* block autoplay for users with `prefers-reduced-motion`, on data
|
||||
* saver, or with strict autoplay policies — so the poster sits
|
||||
* 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.
|
||||
* Three failure modes we defend against:
|
||||
*
|
||||
* 2. Frosted mute toggle pinned bottom-right. Always visible so the
|
||||
* visitor can flip the voice-over on whether they hit play
|
||||
* themselves or it autoplayed.
|
||||
* 1. Browser allows muted autoplay — the happy path. Element loads,
|
||||
* starts playing, the `play` event flips `playing` to true and
|
||||
* hides the overlay.
|
||||
*
|
||||
* Initial state is muted (browser autoplay requires it). On mount we
|
||||
* attempt `.play()` defensively — the promise resolves silently if
|
||||
* autoplay was granted, rejects silently if blocked. Either way the
|
||||
* `play` / `pause` event listeners keep our React state in sync with
|
||||
* whatever the element is actually doing.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function HeroVideo() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playFailed, setPlayFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
// Sync React state with whatever the element actually does.
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPlay = () => {
|
||||
setPlaying(true);
|
||||
setPlayFailed(false);
|
||||
};
|
||||
const onPause = () => setPlaying(false);
|
||||
v.addEventListener('play', onPlay);
|
||||
v.addEventListener('pause', onPause);
|
||||
|
||||
// Best-effort autoplay. Promise rejection = user has to click play.
|
||||
// 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const togglePlay = useCallback(async () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play().catch(() => undefined);
|
||||
setPlayFailed(false);
|
||||
try {
|
||||
// .load() resets the media element's resource selection — required
|
||||
// when an earlier autoplay attempt was blocked before the source
|
||||
// ever fetched. Without it, .play() on a stuck element no-ops.
|
||||
v.load();
|
||||
await v.play();
|
||||
} catch {
|
||||
setPlayFailed(true);
|
||||
}
|
||||
} 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;
|
||||
if (!v) return;
|
||||
@ -74,8 +86,6 @@ export function HeroVideo() {
|
||||
v.muted = next;
|
||||
setMuted(next);
|
||||
if (!next && v.paused) {
|
||||
// Keep playback running after the user enables sound — mobile
|
||||
// Safari pauses on muted-property change mid-playback.
|
||||
v.play().catch(() => undefined);
|
||||
}
|
||||
}, []);
|
||||
@ -94,13 +104,12 @@ export function HeroVideo() {
|
||||
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"
|
||||
>
|
||||
<source src="/videos/hero.webm" type="video/webm" />
|
||||
{/* MP4 only — see file-header note on dropping the WebM source. */}
|
||||
<source src="/videos/hero.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
{/* Centre PLAY button — shown only while paused. Covers the
|
||||
whole video so clicking anywhere starts playback; the inner
|
||||
circle is the visual affordance. */}
|
||||
{/* PLAY overlay — visible while paused. Full-frame so clicking
|
||||
anywhere starts playback (YouTube / Vimeo pattern). */}
|
||||
{!playing && (
|
||||
<button
|
||||
type="button"
|
||||
@ -108,7 +117,8 @@ export function HeroVideo() {
|
||||
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)',
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -122,21 +132,41 @@ export function HeroVideo() {
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{/* `translate-x-0.5` optically centres the play triangle —
|
||||
its visual centre of mass is left of its bounding box. */}
|
||||
{/* translate-x-0.5 optically centres the triangle */}
|
||||
<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. */}
|
||||
{/* 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. */}
|
||||
{playFailed && !playing && (
|
||||
<a
|
||||
href="/videos/hero.mp4"
|
||||
target="_blank"
|
||||
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]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
|
||||
borderColor: 'var(--color-border-strong)',
|
||||
color: 'var(--color-fg-muted)',
|
||||
}}
|
||||
>
|
||||
your browser blocked playback — open the video directly
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Mute toggle — always visible, top of z-stack. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
||||
aria-pressed={!muted}
|
||||
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]"
|
||||
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={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user