Files
Kikomiko/kikomiko/dist/js/atelier-hero.js
T

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
}