144 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|