sinka/web/src/components/landing/scroll-film.tsx

144 lines
4.0 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type ScrollFilmProps = {
src: string;
poster: string;
children: ReactNode;
};
function easeInOutCubic(value: number) {
return value < 0.5 ? 4 * value * value * value : 1 - (-2 * value + 2) ** 3 / 2;
}
export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
const orbitRef = useRef<HTMLDivElement>(null);
const filmRef = useRef<HTMLVideoElement>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const orbit = orbitRef.current;
const film = filmRef.current;
if (!orbit || !film) {
return;
}
film.muted = true;
film.defaultMuted = true;
film.playsInline = true;
film.preload = "auto";
film.pause();
let ready = false;
let current = 0;
let frame = 0;
let blobUrl: string | null = null;
let introStartedAt = 0;
let introDone = false;
const introMs = 2400;
const markReady = () => {
if (ready) {
return;
}
ready = true;
film.pause();
film.classList.add("is-ready");
setLoading(false);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
introDone = reduce;
introStartedAt = reduce ? 0 : performance.now();
};
fetch(src)
.then((response) => response.blob())
.then((blob) => {
blobUrl = URL.createObjectURL(blob);
film.src = blobUrl;
film.load();
})
.catch(() => {
film.src = src;
film.load();
});
const progressFromScroll = () => {
const total = Math.max(orbit.offsetHeight - window.innerHeight, 1);
const y = Math.min(Math.max(-orbit.getBoundingClientRect().top, 0), total);
return y / total;
};
const introSeconds = (now: number, duration: number) => {
if (introDone || !introStartedAt) {
return null;
}
if (progressFromScroll() > 0.02) {
introDone = true;
return null;
}
const elapsed = now - introStartedAt;
const t = Math.min(elapsed / introMs, 1);
const peak = Math.min(2, Math.max(duration * 0.22, 0.6));
const trip = t < 0.5 ? easeInOutCubic(t * 2) : 1 - easeInOutCubic((t - 0.5) * 2);
if (t >= 1) {
introDone = true;
return 0;
}
return trip * peak;
};
const tick = (now: number) => {
const target = progressFromScroll();
current += (target - current) * 0.42;
orbit.style.setProperty("--scrub", current.toFixed(4));
if (ready && film.duration && !film.seeking) {
const intro = introSeconds(now, film.duration);
const time = intro ?? current * (film.duration - 0.04);
if (Math.abs(film.currentTime - time) >= 0.02) {
film.currentTime = time;
}
}
frame = requestAnimationFrame(tick);
};
film.addEventListener("canplaythrough", markReady);
const timeout = window.setTimeout(markReady, 10000);
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
film.removeEventListener("canplaythrough", markReady);
window.clearTimeout(timeout);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [src]);
return (
<div ref={orbitRef} className="orbit" id="topo">
<div className="orbit-stage">
<img alt="" className="orbit-poster" src={poster} />
<video
ref={filmRef}
className="orbit-film"
muted
playsInline
preload="auto"
poster={poster}
/>
<div className="orbit-veil" />
</div>
<div className="orbit-story">{children}</div>
{loading ? (
<div className="orbit-loader" role="status" aria-live="polite" aria-label="Sinka">
<img src="/brand/sinka-logo.webp" alt="Sinka" className="orbit-loader-logo" />
</div>
) : null}
</div>
);
}