445 lines
13 KiB
JavaScript
445 lines
13 KiB
JavaScript
/**
|
|
* Atelier showroom — scroll drives story cards + video scrub on all devices.
|
|
* Light MP4, http(s) src only (no blob/fastSeek), seeked + short fallback unlock.
|
|
*/
|
|
export function initAtelierHero() {
|
|
const root = document.querySelector("[data-atelier]");
|
|
if (!(root instanceof HTMLElement)) return;
|
|
|
|
const track = root.querySelector(".atelier-track");
|
|
const fill = root.querySelector("[data-atelier-fill]");
|
|
const hint = root.querySelector("[data-atelier-hint]");
|
|
const stage = root.querySelector(".atelier-stage");
|
|
const bg = root.querySelector(".atelier-bg");
|
|
const motion = root.querySelector("[data-atelier-motion]");
|
|
const video = root.querySelector("[data-atelier-video]");
|
|
const allFeats = [...root.querySelectorAll("[data-atelier-feat]")];
|
|
const cards = allFeats.filter((el) => el.getAttribute("data-atelier-feat") !== "finale");
|
|
const finaleEl = allFeats.find((el) => el.getAttribute("data-atelier-feat") === "finale");
|
|
const dots = [...root.querySelectorAll("[data-atelier-dot]")];
|
|
|
|
if (!(track instanceof HTMLElement) || !cards.length) return;
|
|
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
|
const storyMode = root.hasAttribute("data-atelier-story");
|
|
const pairCount = storyMode ? cards.length : Math.ceil(cards.length / 2);
|
|
const stepCount = pairCount + (finaleEl ? 1 : 0);
|
|
const finaleStep = stepCount - 1;
|
|
|
|
let active = true;
|
|
let raf = 0;
|
|
let ticking = false;
|
|
let lastStep = -1;
|
|
let lastAppliedP = -1;
|
|
let hintHidden = false;
|
|
let videoReady = false;
|
|
let scrubDuration = 0;
|
|
let seeking = false;
|
|
let seekFallback = 0;
|
|
let unlocking = false;
|
|
let decoderUnlocked = false;
|
|
let targetP = 0;
|
|
let smoothP = 0;
|
|
let targetTime = 0;
|
|
let rootTop = 0;
|
|
let trackHeight = 0;
|
|
let metricsDirty = true;
|
|
let scrollScrub = !reduce;
|
|
|
|
const SCRUB_END_TRIM_SEC = 0.08;
|
|
const SEEK_EPS = coarsePointer ? 0.08 : 0.04;
|
|
const VIDEO_SRC = "/videos/kiko-hero-motion-v3.mp4";
|
|
|
|
const viewportH = () =>
|
|
window.visualViewport?.height ?? window.innerHeight;
|
|
|
|
const refreshDuration = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
if (!(Number.isFinite(video.duration) && video.duration > 0)) return;
|
|
scrubDuration = Math.max(0.5, video.duration - SCRUB_END_TRIM_SEC);
|
|
};
|
|
|
|
const measure = () => {
|
|
trackHeight = track.offsetHeight;
|
|
rootTop = root.getBoundingClientRect().top;
|
|
metricsDirty = false;
|
|
};
|
|
|
|
const lockVideoUi = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
video.controls = false;
|
|
video.removeAttribute("controls");
|
|
video.setAttribute("controlslist", "nodownload nofullscreen noremoteplayback");
|
|
try {
|
|
video.disablePictureInPicture = true;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
video.muted = true;
|
|
video.defaultMuted = true;
|
|
video.playsInline = true;
|
|
video.loop = false;
|
|
video.removeAttribute("autoplay");
|
|
video.removeAttribute("loop");
|
|
video.setAttribute("muted", "");
|
|
video.setAttribute("playsinline", "");
|
|
video.setAttribute("webkit-playsinline", "");
|
|
video.setAttribute("disablepictureinpicture", "");
|
|
// metadata keeps LCP bandwidth free; bump to auto on first scrub gesture
|
|
video.preload = "metadata";
|
|
};
|
|
|
|
const preferFullVideoBuffer = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
if (video.preload === "auto") return;
|
|
video.preload = "auto";
|
|
};
|
|
|
|
const clearSeekFallback = () => {
|
|
if (seekFallback) {
|
|
window.clearTimeout(seekFallback);
|
|
seekFallback = 0;
|
|
}
|
|
};
|
|
|
|
const unlockDecoder = () => {
|
|
if (!(video instanceof HTMLVideoElement) || decoderUnlocked || unlocking) return;
|
|
unlocking = true;
|
|
const unlock = video.play();
|
|
const done = () => {
|
|
try {
|
|
video.pause();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
unlocking = false;
|
|
decoderUnlocked = true;
|
|
refreshDuration();
|
|
if (scrollScrub && active) startLoop();
|
|
};
|
|
if (unlock && typeof unlock.then === "function") {
|
|
unlock.then(done).catch(done);
|
|
} else {
|
|
done();
|
|
}
|
|
};
|
|
|
|
const applySeek = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
if (!scrubDuration || seeking || unlocking) return;
|
|
if (Math.abs(video.currentTime - targetTime) < SEEK_EPS) return;
|
|
|
|
seeking = true;
|
|
clearSeekFallback();
|
|
seekFallback = window.setTimeout(() => {
|
|
seeking = false;
|
|
seekFallback = 0;
|
|
if (scrollScrub && active) startLoop();
|
|
}, coarsePointer ? 260 : 200);
|
|
|
|
try {
|
|
if (!video.paused) video.pause();
|
|
video.currentTime = targetTime;
|
|
} catch {
|
|
seeking = false;
|
|
clearSeekFallback();
|
|
}
|
|
};
|
|
|
|
const ensureVideo = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
if (!videoReady) {
|
|
lockVideoUi();
|
|
videoReady = true;
|
|
const src =
|
|
video.getAttribute("data-src") ||
|
|
video.getAttribute("src") ||
|
|
VIDEO_SRC;
|
|
if ((video.getAttribute("src") || "") !== src) {
|
|
video.src = src;
|
|
}
|
|
video.load();
|
|
}
|
|
refreshDuration();
|
|
};
|
|
|
|
const onMeta = () => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
refreshDuration();
|
|
if (scrubDuration > 0) {
|
|
unlockDecoder();
|
|
if (scrollScrub && active) startLoop();
|
|
}
|
|
};
|
|
|
|
if (video instanceof HTMLVideoElement) {
|
|
video.addEventListener("loadedmetadata", onMeta);
|
|
video.addEventListener("loadeddata", onMeta);
|
|
video.addEventListener("canplay", onMeta);
|
|
video.addEventListener("durationchange", refreshDuration);
|
|
video.addEventListener("seeked", () => {
|
|
seeking = false;
|
|
clearSeekFallback();
|
|
if (!scrollScrub || !active) return;
|
|
if (Math.abs(video.currentTime - targetTime) > SEEK_EPS) applySeek();
|
|
else startLoop();
|
|
});
|
|
video.addEventListener("error", () => {
|
|
seeking = false;
|
|
clearSeekFallback();
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
video.src = VIDEO_SRC;
|
|
video.load();
|
|
});
|
|
}
|
|
|
|
const scrubToward = (progress) => {
|
|
if (!(video instanceof HTMLVideoElement)) return;
|
|
if (!scrubDuration) {
|
|
ensureVideo();
|
|
if (!scrubDuration) return;
|
|
}
|
|
const end = Math.max(0.05, scrubDuration - 0.04);
|
|
targetTime = Math.min(end, Math.max(0, progress * end));
|
|
applySeek();
|
|
};
|
|
|
|
const setStep = (index) => {
|
|
const idx = Math.max(0, Math.min(finaleStep, index | 0));
|
|
if (idx === lastStep) return;
|
|
lastStep = idx;
|
|
const isFinale = idx >= pairCount;
|
|
root.classList.toggle("is-finale", isFinale);
|
|
root.dataset.scene = String(idx);
|
|
root.dataset.pair = isFinale ? "finale" : String(idx);
|
|
|
|
cards.forEach((el, i) => {
|
|
const step = storyMode ? i : Math.floor(i / 2);
|
|
const on = !isFinale && step === idx;
|
|
el.classList.toggle("is-active", on);
|
|
el.classList.toggle("is-passed", !isFinale ? step < idx : true);
|
|
el.setAttribute("aria-hidden", on ? "false" : "true");
|
|
});
|
|
|
|
if (finaleEl instanceof HTMLElement) {
|
|
finaleEl.classList.toggle("is-active", isFinale);
|
|
finaleEl.setAttribute("aria-hidden", isFinale ? "false" : "true");
|
|
}
|
|
|
|
dots.forEach((d, i) => {
|
|
d.classList.toggle("is-active", i === idx);
|
|
d.classList.toggle("is-done", i < idx);
|
|
});
|
|
|
|
if (motion instanceof HTMLElement) {
|
|
motion.classList.toggle("is-exit", isFinale);
|
|
}
|
|
};
|
|
|
|
setStep(0);
|
|
|
|
if (reduce) {
|
|
scrollScrub = false;
|
|
root.classList.add("is-reduced");
|
|
track.style.display = "none";
|
|
if (video instanceof HTMLVideoElement) {
|
|
lockVideoUi();
|
|
ensureVideo();
|
|
video.pause();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const applyVisual = (p) => {
|
|
const nextStep = Math.min(finaleStep, Math.floor(p * stepCount));
|
|
const stepChanged = nextStep !== lastStep;
|
|
if (lastAppliedP >= 0 && Math.abs(p - lastAppliedP) < 0.0004 && !stepChanged) {
|
|
scrubToward(p);
|
|
return;
|
|
}
|
|
lastAppliedP = p;
|
|
|
|
if (p > 0.012 && !hintHidden) {
|
|
hintHidden = true;
|
|
root.classList.add("is-past-hint");
|
|
if (hint instanceof HTMLElement) hint.hidden = true;
|
|
}
|
|
|
|
if (fill instanceof HTMLElement) fill.style.transform = `scaleY(${p})`;
|
|
if (bg instanceof HTMLElement) bg.style.setProperty("--atelier-p", p.toFixed(4));
|
|
if (stage instanceof HTMLElement) {
|
|
stage.style.setProperty("--atelier-p", p.toFixed(4));
|
|
const isFinale = nextStep >= pairCount;
|
|
const exit = isFinale ? 1 : p > 0.86 ? (p - 0.86) / 0.14 : 0;
|
|
stage.style.setProperty("--atelier-exit", exit.toFixed(4));
|
|
}
|
|
|
|
setStep(nextStep);
|
|
scrubToward(p);
|
|
};
|
|
|
|
const readTarget = () => {
|
|
if (metricsDirty) measure();
|
|
else rootTop = root.getBoundingClientRect().top;
|
|
|
|
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
|
|
const scrolled = Math.min(scrollable, Math.max(0, -rootTop));
|
|
return scrolled / scrollable;
|
|
};
|
|
|
|
const tick = () => {
|
|
raf = 0;
|
|
if (!active) {
|
|
ticking = false;
|
|
return;
|
|
}
|
|
|
|
targetP = readTarget();
|
|
const gap = Math.abs(targetP - smoothP);
|
|
const ease = gap > 0.06 ? 0.88 : gap > 0.015 ? 0.68 : 0.48;
|
|
smoothP += (targetP - smoothP) * ease;
|
|
if (gap < 0.00008) smoothP = targetP;
|
|
|
|
applyVisual(smoothP);
|
|
|
|
const needsSeek =
|
|
scrollScrub &&
|
|
(seeking || Math.abs((video?.currentTime || 0) - targetTime) > SEEK_EPS);
|
|
|
|
if (Math.abs(targetP - smoothP) > 0.0001 || needsSeek) {
|
|
raf = requestAnimationFrame(tick);
|
|
} else {
|
|
ticking = false;
|
|
}
|
|
};
|
|
|
|
const startLoop = () => {
|
|
if (!active || !scrollScrub) return;
|
|
if (!ticking) {
|
|
ticking = true;
|
|
raf = requestAnimationFrame(tick);
|
|
}
|
|
};
|
|
|
|
const scrollToStep = (index) => {
|
|
const idx = Math.max(0, Math.min(finaleStep, index | 0));
|
|
metricsDirty = true;
|
|
measure();
|
|
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
|
|
const top =
|
|
window.scrollY +
|
|
root.getBoundingClientRect().top +
|
|
((idx + 0.2) / stepCount) * scrollable;
|
|
window.scrollTo({ top, behavior: "smooth" });
|
|
startLoop();
|
|
};
|
|
|
|
const onUserScroll = () => {
|
|
preferFullVideoBuffer();
|
|
if (!decoderUnlocked) unlockDecoder();
|
|
startLoop();
|
|
};
|
|
|
|
window.addEventListener("scroll", onUserScroll, { passive: true });
|
|
window.addEventListener(
|
|
"resize",
|
|
() => {
|
|
metricsDirty = true;
|
|
startLoop();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
window.visualViewport?.addEventListener(
|
|
"resize",
|
|
() => {
|
|
metricsDirty = true;
|
|
startLoop();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
window.visualViewport?.addEventListener("scroll", onUserScroll, { passive: true });
|
|
window.addEventListener(
|
|
"touchstart",
|
|
() => {
|
|
preferFullVideoBuffer();
|
|
ensureVideo();
|
|
unlockDecoder();
|
|
startLoop();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
window.addEventListener("touchmove", onUserScroll, { passive: true });
|
|
window.addEventListener("wheel", onUserScroll, { passive: true });
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const ro = new ResizeObserver(() => {
|
|
metricsDirty = true;
|
|
startLoop();
|
|
});
|
|
ro.observe(root);
|
|
ro.observe(track);
|
|
}
|
|
|
|
dots.forEach((dot, i) => {
|
|
if (!(dot instanceof HTMLElement)) return;
|
|
dot.setAttribute("role", "button");
|
|
dot.setAttribute("tabindex", "0");
|
|
dot.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
scrollToStep(i);
|
|
});
|
|
});
|
|
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
const e = entries[0];
|
|
if (!e) return;
|
|
active = e.isIntersecting;
|
|
document.documentElement.classList.toggle("is-atelier-scrub", active && scrollScrub);
|
|
if (active) {
|
|
metricsDirty = true;
|
|
ensureVideo();
|
|
startLoop();
|
|
} else {
|
|
if (video instanceof HTMLVideoElement) {
|
|
try {
|
|
video.pause();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
ticking = false;
|
|
if (raf) {
|
|
cancelAnimationFrame(raf);
|
|
raf = 0;
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "180px 0px", threshold: [0, 0.01, 0.1] }
|
|
);
|
|
io.observe(root);
|
|
|
|
if (window.matchMedia("(pointer: fine)").matches) {
|
|
cards.forEach((mod) => {
|
|
if (!(mod instanceof HTMLElement)) return;
|
|
mod.addEventListener("pointermove", (ev) => {
|
|
if (!mod.classList.contains("is-active")) return;
|
|
const r = mod.getBoundingClientRect();
|
|
mod.style.setProperty("--mx", (((ev.clientX - r.left) / r.width - 0.5) * 10).toFixed(2));
|
|
mod.style.setProperty("--my", (((ev.clientY - r.top) / r.height - 0.5) * 8).toFixed(2));
|
|
});
|
|
mod.addEventListener("pointerleave", () => {
|
|
mod.style.setProperty("--mx", "0");
|
|
mod.style.setProperty("--my", "0");
|
|
});
|
|
});
|
|
}
|
|
|
|
ensureVideo();
|
|
measure();
|
|
startLoop();
|
|
// Decoder unlock (play/pause) pulls media bytes — wait for first scrub gesture
|
|
}
|