/** * 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 }