Files
hyperframes/docs/snippets/docs-video.jsx
Ular Kimsanov 5472d9eac0 docs: the docs in hyperframes.dev's and the Studio app's colours (#6393)
* docs: the docs in hyperframes.dev's and the Studio app's colours

Light mode takes hyperframes.dev's look: a still of its beige light
ground, a frosted header, its ink, black pill buttons, heavy TT Norms
headlines and a light-weight description. Every other colour comes from
the Studio app: Mintlify's gray scale on the app's warm hue, article
text and catalog greys on the same tokens, and code in the browser
editor's own syntax colours. Dark mode is the app's Night palette.

* docs: code in the browser editor's colours on the hosted build too

Mintlify's hosted build ignores a custom code theme (the preview kept
GitHub's colours), so the code-theme setting goes and each colour of the
default themes (GitHub light, VS Code dark) is mapped by role to the
browser editor's colour for that role. Code blocks and the fade under the
copy button use the code token in both modes.

* docs: map the dark control keywords to the editor keyword colour
2026-10-26 06:40:08 +00:00

586 lines
20 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const DocsVideo = ({
src,
poster,
title,
autoPlay = false,
loop = false,
portrait = false,
aspect, // e.g. "16 / 10" for app recordings; the frame is 16 / 9 otherwise
}) => {
const videoRef = useRef(null);
const playerRef = useRef(null);
const hideTimerRef = useRef(null);
const progressFrameRef = useRef(null);
const [enhanced, setEnhanced] = useState(false);
const [playing, setPlaying] = useState(false);
const [waiting, setWaiting] = useState(false);
// Browsers only autoplay muted video, so an autoplaying player starts muted; the sound button unmutes it.
const [muted, setMuted] = useState(autoPlay);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [playbackRate, setPlaybackRate] = useState(1);
// Lazy, like ShowcaseWall's: a reduce-motion visitor's first render must not autoplay. The controls still play it.
const [reduced, setReduced] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (event) => setReduced(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
// Turning Reduce Motion on mid-visit must stop an autoplaying loop for real: React props can't pause it or drop
// its file. The play button loads it again (togglePlayback).
useEffect(() => {
const video = videoRef.current;
if (!reduced || !autoPlay || !video) return;
video.pause();
video.removeAttribute("src");
video.load();
}, [reduced, autoPlay]);
const [controlsVisible, setControlsVisible] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [fullscreenSupported, setFullscreenSupported] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [scrubbing, setScrubbing] = useState(false);
const [previewTime, setPreviewTime] = useState(0);
const [previewPosition, setPreviewPosition] = useState(0);
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${minutes}:${String(remaining).padStart(2, "0")}`;
};
const clearHideTimer = () => {
if (hideTimerRef.current) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
const revealControls = () => {
setControlsVisible(true);
clearHideTimer();
hideTimerRef.current = window.setTimeout(() => setControlsVisible(false), 2200);
};
const togglePlayback = async () => {
const video = videoRef.current;
if (!video) return;
if (video.paused || video.ended) {
if (!video.getAttribute("src")) video.src = src; // dropped when Reduce Motion stopped the loop
if (video.ended) video.currentTime = 0;
setWaiting(true);
try {
await video.play();
} catch {
setWaiting(false);
setPlaying(false);
}
} else {
video.pause();
setControlsVisible(true);
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
if (video.muted && video.volume === 0) video.volume = 0.8;
video.muted = !video.muted;
setMuted(video.muted);
};
const seek = (event) => {
const video = videoRef.current;
if (!video) return;
const nextTime = Number(event.target.value);
video.currentTime = nextTime;
setCurrentTime(nextTime);
};
const updateScrubPreview = (event, seekMainVideo = false) => {
if (!duration) return;
const rect = event.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const nextTime = ratio * duration;
setPreviewing(true);
setPreviewTime(nextTime);
setPreviewPosition(ratio * 100);
if (seekMainVideo) {
const video = videoRef.current;
if (video) {
video.currentTime = nextTime;
setCurrentTime(nextTime);
}
}
};
const cyclePlaybackRate = () => {
const video = videoRef.current;
if (!video) return;
const rates = [1, 1.25, 1.5, 2];
const currentIndex = rates.indexOf(video.playbackRate);
const nextRate = rates[(currentIndex + 1) % rates.length];
video.playbackRate = nextRate;
setPlaybackRate(nextRate);
};
const toggleFullscreen = async () => {
const player = playerRef.current;
const video = videoRef.current;
if (!player || typeof document === "undefined") return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else if (player.requestFullscreen) {
await player.requestFullscreen();
} else if (video?.webkitEnterFullscreen) {
video.webkitEnterFullscreen();
}
} catch {
// The normal inline player remains fully usable when fullscreen is blocked.
}
};
const handleKeyboard = (event) => {
if (event.target !== event.currentTarget) return;
const video = videoRef.current;
if (!video) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
togglePlayback();
} else if (event.key === "ArrowLeft") {
event.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
} else if (event.key === "ArrowRight") {
event.preventDefault();
video.currentTime = Math.min(duration || video.duration || 0, video.currentTime + 5);
} else if (event.key.toLowerCase() === "m") {
event.preventDefault();
toggleMute();
} else if (event.key.toLowerCase() === "f") {
event.preventDefault();
toggleFullscreen();
}
};
useEffect(() => {
setEnhanced(true);
setFullscreenSupported(
Boolean(playerRef.current?.requestFullscreen || videoRef.current?.webkitEnterFullscreen),
);
return () => {
clearHideTimer();
};
}, []);
useEffect(() => {
if (typeof document === "undefined") return undefined;
const syncFullscreen = () => setFullscreen(document.fullscreenElement === playerRef.current);
document.addEventListener("fullscreenchange", syncFullscreen);
return () => document.removeEventListener("fullscreenchange", syncFullscreen);
}, []);
useEffect(() => {
clearHideTimer();
if (!playing) return undefined;
hideTimerRef.current = window.setTimeout(() => setControlsVisible(false), 2200);
return clearHideTimer;
}, [playing]);
useEffect(() => {
if (!playing) return undefined;
const updateProgress = () => {
const video = videoRef.current;
if (video && !video.paused) setCurrentTime(video.currentTime);
progressFrameRef.current = window.requestAnimationFrame(updateProgress);
};
progressFrameRef.current = window.requestAnimationFrame(updateProgress);
return () => {
if (progressFrameRef.current) window.cancelAnimationFrame(progressFrameRef.current);
progressFrameRef.current = null;
};
}, [playing]);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const replaying = duration > 0 && currentTime >= duration - 0.15;
return (
<div className="hf-docs-video-block" data-portrait={portrait ? "true" : "false"}>
<div
ref={playerRef}
className="hf-docs-video"
style={aspect ? { aspectRatio: aspect } : undefined}
role="region"
aria-label={title}
tabIndex={0}
onKeyDown={handleKeyboard}
onPointerMove={revealControls}
onPointerLeave={() => setControlsVisible(false)}
onFocus={revealControls}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setControlsVisible(false);
}}
>
<video
ref={videoRef}
aria-label={title}
src={src}
poster={poster}
autoPlay={autoPlay && !reduced}
muted={autoPlay}
loop={loop}
playsInline
preload="metadata"
controls={!enhanced}
onClick={togglePlayback}
onDoubleClick={toggleFullscreen}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration || 0;
setDuration(nextDuration);
setMuted(event.currentTarget.muted);
}}
onDurationChange={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onPlaying={() => setWaiting(false)}
onWaiting={() => setWaiting(true)}
onCanPlay={() => setWaiting(false)}
onEnded={() => {
setPlaying(false);
setControlsVisible(true);
}}
onVolumeChange={(event) => setMuted(event.currentTarget.muted)}
/>
{enhanced && (
<>
{!playing && (currentTime <= 0.2 || replaying) && (
<button
type="button"
className="hf-docs-video-hero-play"
onClick={togglePlayback}
aria-label={replaying ? "Replay video" : "Play video"}
>
<span className="hf-docs-video-hero-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M8 5.5v13l10-6.5z" />
</svg>
</span>
</button>
)}
{waiting && playing && <span className="hf-docs-video-spinner" aria-label="Loading" />}
<div
className="hf-docs-video-controls"
data-visible={controlsVisible ? "true" : "false"}
>
<div
className="hf-docs-video-scrub-preview"
data-visible={previewing ? "true" : "false"}
style={{ "--hf-video-preview-x": `${previewPosition}%` }}
aria-hidden="true"
>
<span>{formatTime(previewTime)}</span>
</div>
<input
className="hf-docs-video-progress"
type="range"
min="0"
max={duration || 0}
step="0.01"
value={Math.min(currentTime, duration || 0)}
aria-label="Video progress"
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
onChange={seek}
onPointerEnter={updateScrubPreview}
onPointerMove={(event) =>
updateScrubPreview(event, scrubbing || event.buttons === 1)
}
onPointerDown={(event) => {
setScrubbing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
updateScrubPreview(event, true);
}}
onPointerUp={(event) => {
setScrubbing(false);
if (event.pointerType !== "mouse") setPreviewing(false);
}}
onPointerCancel={() => {
setScrubbing(false);
setPreviewing(false);
}}
onPointerLeave={() => {
if (!scrubbing) setPreviewing(false);
}}
style={{ "--hf-video-progress": `${progress}%` }}
/>
<div className="hf-docs-video-control-row">
<button
type="button"
className="hf-docs-video-control"
onClick={togglePlayback}
aria-label={playing ? "Pause video" : "Play video"}
>
{playing ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 5h4v14H7zm6 0h4v14h-4z" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5.5v13l10-6.5z" />
</svg>
)}
</button>
<button
type="button"
className="hf-docs-video-control"
onClick={toggleMute}
aria-label={muted ? "Unmute video" : "Mute video"}
>
{muted ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 9v6h4l5 4V5L8 9zm11.5 1.1 1.4-1.4 1.6 1.6 1.6-1.6 1.4 1.4-1.6 1.6 1.6 1.6-1.4 1.4-1.6-1.6-1.6 1.6-1.4-1.4 1.6-1.6z" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 9v6h4l5 4V5L8 9zm11 1.2v3.6c1-.5 1.7-1.5 1.7-2.8S16 10.7 15 10.2zm0-4v2.1c2.2.6 3.7 2.5 3.7 4.7s-1.5 4.1-3.7 4.7v2.1c3.3-.7 5.7-3.5 5.7-6.8S18.3 6.9 15 6.2z" />
</svg>
)}
</button>
<span className="hf-docs-video-time" aria-hidden="true">
{formatTime(currentTime)} <span>/</span> {formatTime(duration)}
</span>
<span className="hf-docs-video-spacer" />
<button
type="button"
className="hf-docs-video-rate"
onClick={cyclePlaybackRate}
aria-label={`Playback speed ${playbackRate} times`}
>
{playbackRate}×
</button>
{fullscreenSupported && (
<button
type="button"
className="hf-docs-video-control"
onClick={toggleFullscreen}
aria-label={fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
{fullscreen ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3H6v3H3v2h5zm8 0v5h5V6h-3V3zM3 16v2h3v3h2v-5zm13 0v5h2v-3h3v-2z" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 8h2V5h3V3H3zm13-5v2h3v3h2V3zM5 16H3v5h5v-2H5zm14 3h-3v2h5v-5h-2z" />
</svg>
)}
</button>
)}
</div>
</div>
</>
)}
</div>
</div>
);
};
export const ShowcaseWall = () => {
// The same films as the homepage showcase (hyperframes.heygen.com), from the team and ours: a tile plays the 720p
// card, an opened film the 1080p copy.
const CDN = "https://static.heygen.ai/hyperframes-oss/homepage/showcase";
const homepageFilm = (id, v, title, note, length) => ({
id,
title,
note,
length,
tile: `${CDN}/card/${id}-v${v}.mp4`,
full: `${CDN}/hq/${id}-v${v}.mp4`,
poster: `${CDN}/poster/${id}-v${v}.jpg`,
});
const films = [
homepageFilm("frameys-opening-night", 1, "Opening night", "Awards trailer", "50s"),
homepageFilm("real-ui-newspaper-1969", 2, "Men walk on the Moon", "Real UI, rebuilt in code", "8s"),
homepageFilm("manga-last-onigiri", 1, "The last onigiri", "Manga", "26s"),
homepageFilm("vfx-background-removal-launch", 1, "Background removal", "VFX", "43s"),
homepageFilm("cow", 1, "Cow 1.0", "Launch parody", "30s"),
homepageFilm("remotion-to-hyperframes", 1, "Remotion to HyperFrames", "3D voxel", "20s"),
];
const [openId, setOpenId] = useState(null);
// Lazy initializer, not a post-mount effect: with useState(false) the first
// committed render emits <video src autoPlay loop> and only then drops the
// attributes, so a reduce-motion visitor has already started fetching every
// tile. autoPlay also overrides preload="metadata", and removing src without
// a following load() is not a reliable abort. CSS cannot reach any of this.
const wallRef = useRef(null);
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (event) => setReduced(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
// React can drop src/autoPlay/loop from the DOM, but neither pauses a playing
// element nor aborts its selected resource: a media element keeps its current
// resource until the load algorithm is re-invoked, and `autoplay` only governs
// the first play. So a visitor who turns Reduce Motion on mid-session would
// otherwise keep every tile playing and downloading. Stop them for real.
useEffect(() => {
if (!reduced || !wallRef.current) return;
for (const video of wallRef.current.querySelectorAll("video")) {
video.pause();
video.removeAttribute("src");
video.load();
}
}, [reduced]);
const open = films.find((film) => film.id === openId) || null;
if (open) {
return (
<div style={{ margin: "1.5rem 0" }}>
{/* A plain video, not DocsVideo: a Mintlify snippet can't use another export of its file, and the reference
alone stopped the whole wall from rendering. Opened by a click, so it may start with sound. */}
<video
key={open.id}
src={open.full}
poster={open.poster}
aria-label={open.title}
controls
autoPlay
playsInline
style={{ display: "block", width: "100%", borderRadius: "12px", background: "#000" }}
/>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: "1rem",
marginTop: "0.6rem",
flexWrap: "wrap",
}}
>
<span style={{ fontSize: "0.875rem", opacity: 0.75 }}>
<strong>{open.title}</strong> · {open.length} · made with HyperFrames
</span>
<button
type="button"
onClick={() => setOpenId(null)}
style={{
fontSize: "0.8125rem",
padding: "0.35rem 0.7rem",
borderRadius: "7px",
border: "1px solid currentColor",
background: "transparent",
color: "inherit",
opacity: 0.7,
cursor: "pointer",
}}
>
← Back to all films
</button>
</div>
</div>
);
}
return (
<div
ref={wallRef}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: "0.85rem",
margin: "1.5rem 0",
}}
>
{films.map((film) => (
<button
key={film.id}
type="button"
onClick={() => setOpenId(film.id)}
aria-label={`Play ${film.title}, ${film.length}`}
style={{
display: "block",
width: "100%",
padding: 0,
border: "1px solid var(--hf-border-color)",
borderRadius: "11px",
overflow: "hidden",
background: "transparent",
color: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<video
src={reduced ? undefined : film.tile}
poster={film.poster}
autoPlay={!reduced}
muted
loop={!reduced}
playsInline
preload="metadata"
style={{
width: "100%",
aspectRatio: "16 / 9",
objectFit: "cover",
display: "block",
background: "#000",
margin: 0,
}}
/>
<span style={{ display: "block", padding: "0.7rem 0.85rem 0.85rem" }}>
<span style={{ display: "block", fontWeight: 600, fontSize: "0.9375rem" }}>
{film.title}
</span>
<span
style={{
display: "block",
fontSize: "0.8125rem",
opacity: 0.7,
marginTop: "0.15rem",
}}
>
{film.note}
</span>
<span
style={{
display: "block",
fontSize: "0.75rem",
opacity: 0.55,
marginTop: "0.35rem",
}}
>
▶ Play · {film.length}
</span>
</span>
</button>
))}
</div>
);
};