fix(video): drop WebM source + load()-before-play() + open-in-tab fallback
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:
Marco Sadjadi 2026-05-28 03:26:56 +02:00
parent b464b5640f
commit 05746e13e6

View File

@ -1,72 +1,84 @@
'use client'; 'use client';
import { Play, Volume2, VolumeX } from 'lucide-react'; import { ExternalLink, Play, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from '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 * Three failure modes we defend against:
* 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.
* *
* 2. Frosted mute toggle pinned bottom-right. Always visible so the * 1. Browser allows muted autoplay the happy path. Element loads,
* visitor can flip the voice-over on whether they hit play * starts playing, the `play` event flips `playing` to true and
* themselves or it autoplayed. * hides the overlay.
* *
* Initial state is muted (browser autoplay requires it). On mount we * 2. Browser blocks autoplay but allows playback on user gesture
* attempt `.play()` defensively the promise resolves silently if * the play overlay sits over the poster, the user clicks, we
* autoplay was granted, rejects silently if blocked. Either way the * call `.load()` first to reset the resource-selection state
* `play` / `pause` event listeners keep our React state in sync with * (some Chrome builds park the element at networkState=2/
* whatever the element is actually doing. * 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 * 3. Browser refuses to play even after the gesture extension-
* muted-property change mid-playback. Swallow the promise failure * sandboxed contexts, hardware-decoder failures, etc. We catch
* just means the user can hit play again, which the overlay already * the promise rejection, surface a small "open video in a new
* affords. * 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() { 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 [playing, setPlaying] = useState(false);
const [playFailed, setPlayFailed] = useState(false);
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onPlay = () => {
// Sync React state with whatever the element actually does. setPlaying(true);
const onPlay = () => setPlaying(true); setPlayFailed(false);
};
const onPause = () => setPlaying(false); const onPause = () => setPlaying(false);
v.addEventListener('play', onPlay); v.addEventListener('play', onPlay);
v.addEventListener('pause', onPause); v.addEventListener('pause', onPause);
// Best-effort autoplay attempt — silently fail on browsers that
// Best-effort autoplay. Promise rejection = user has to click play. // 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);
}; };
}, []); }, []);
const togglePlay = useCallback(() => { const togglePlay = useCallback(async () => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
if (v.paused) { 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 { } else {
v.pause(); v.pause();
} }
}, []); }, []);
const toggleMute = useCallback((e: React.MouseEvent) => { 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(); e.stopPropagation();
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
@ -74,8 +86,6 @@ export function HeroVideo() {
v.muted = next; v.muted = next;
setMuted(next); setMuted(next);
if (!next && v.paused) { 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); v.play().catch(() => undefined);
} }
}, []); }, []);
@ -94,13 +104,12 @@ export function HeroVideo() {
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"
> >
<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" /> <source src="/videos/hero.mp4" type="video/mp4" />
</video> </video>
{/* Centre PLAY button shown only while paused. Covers the {/* PLAY overlay visible while paused. Full-frame so clicking
whole video so clicking anywhere starts playback; the inner anywhere starts playback (YouTube / Vimeo pattern). */}
circle is the visual affordance. */}
{!playing && ( {!playing && (
<button <button
type="button" type="button"
@ -108,7 +117,8 @@ export function HeroVideo() {
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: 'color-mix(in oklab, var(--color-bg) 30%, transparent)', backgroundColor:
'color-mix(in oklab, var(--color-bg) 30%, transparent)',
}} }}
> >
<div <div
@ -122,21 +132,41 @@ export function HeroVideo() {
color: 'var(--color-accent)', color: 'var(--color-accent)',
}} }}
> >
{/* `translate-x-0.5` optically centres the play triangle {/* translate-x-0.5 optically centres the triangle */}
its visual centre of mass is left of its bounding box. */}
<Play size={32} fill="currentColor" className="translate-x-0.5" /> <Play size={32} fill="currentColor" className="translate-x-0.5" />
</div> </div>
</button> </button>
)} )}
{/* Mute toggle always visible, top of z-stack so it stays {/* Fallback escape hatch surfaces only if .play() rejects even
clickable even when the play overlay is up. */} 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 <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-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={{ style={{
backgroundColor: backgroundColor:
'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)', 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',