"use client"; import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { chapterFraction, chapterIndexAt, getScrubSource, getShowcaseAssets, mobileFractionAt, } from "@/lib/project-showcase"; import { BrowserFrame, displayUrl } from "./BrowserFrame"; import { PhoneFrame } from "./PhoneFrame"; import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider"; import { easeToward, locateInReel, REEL_HOLD, reelOffset, videoProgress, } from "./showcase-math"; // a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro let headerCache = { width: -1, height: 60 }; const headerHeight = () => { if (headerCache.width !== window.innerWidth) headerCache = { width: window.innerWidth, height: Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue( "--site-header-height", ), ) || 60, }; return headerCache.height; }; /** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */ function usePhoneOnly() { const [phoneOnly, setPhoneOnly] = useState(false); useEffect(() => { if (typeof window.matchMedia !== "function") return; const query = window.matchMedia("(max-width: 639px)"); const update = () => setPhoneOnly(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); return phoneOnly; } /** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */ const saveDataOn = () => (navigator as Navigator & { connection?: { saveData?: boolean } }).connection ?.saveData === true; /** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */ function seekVideo(video: HTMLVideoElement | null, fraction: number) { if (!video || video.readyState < 1 || !video.duration) return; const target = Math.min( video.duration - 0.001, Math.max(0, fraction * video.duration), ); if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48) video.currentTime = target; } /** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */ function prime(video: HTMLVideoElement) { if (video.dataset.primed) return; video.dataset.primed = "true"; video .play() .then(() => video.pause()) .catch(() => {}); } let stopScroll = () => {}; /** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */ function scrollToSmoothly(top: number) { stopScroll(); const from = window.scrollY; const distance = top - from; const duration = Math.min(900, 380 + Math.abs(distance) * 0.12); const started = performance.now(); let id = 0; const stop = () => { cancelAnimationFrame(id); window.removeEventListener("wheel", stop); window.removeEventListener("touchstart", stop); }; stopScroll = stop; window.addEventListener("wheel", stop, { passive: true }); window.addEventListener("touchstart", stop, { passive: true }); const step = (now: number) => { const t = Math.min(1, (now - started) / duration); const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; // "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação window.scrollTo({ top: from + distance * eased, behavior: "instant" }); if (t < 1) id = requestAnimationFrame(step); else stop(); }; id = requestAnimationFrame(step); } /** * Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a * página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem * um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular * mostram o mesmo trecho do site ao mesmo tempo. * * Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel. */ export function ProjectReel() { const items = useShowcaseItems(); const openProject = useShowcaseOpen(); const reducedMotion = useReducedMotion(); const [mounted, setMounted] = useState(false); const [active, setActive] = useState(0); const [chapter, setChapter] = useState(0); const [near, setNear] = useState(false); const [settled, setSettled] = useState(0); const [saveData, setSaveData] = useState(false); const phoneOnly = usePhoneOnly(); const containerRef = useRef(null); const pinRef = useRef(null); const indexRef = useRef(null); const desktopVideos = useRef>([]); const phoneVideos = useRef>([]); const bars = useRef>([]); const live = useRef({ target: [] as number[], smooth: [] as number[], active: 0, chapter: 0, raf: 0, last: 0, near: false, }); const weights = useMemo( () => items.map((item) => item.config.weight), [items], ); const weightSum = weights.reduce((sum, weight) => sum + weight, 0); useEffect(() => { setSaveData(saveDataOn()); setMounted(true); }, []); // só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por // um projeto no clique do índice não pode baixar todos os do caminho) useEffect(() => { const timer = setTimeout(() => setSettled(active), 300); return () => clearTimeout(timer); }, [active]); // na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista useEffect(() => { const list = indexRef.current; const button = list?.children[active]?.querySelector("button"); if ( !list || !button || list.scrollWidth <= list.clientWidth || typeof list.scrollTo !== "function" ) return; list.scrollTo({ left: Math.max(0, button.offsetLeft - 16), behavior: "smooth", }); }, [active]); const isAttached = useCallback( (index: number) => near && !saveData && Math.abs(index - settled) <= 1, [near, saveData, settled], ); // quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória) useEffect(() => { items.forEach((_, index) => { if (isAttached(index)) return; for (const video of [ desktopVideos.current[index], phoneVideos.current[index], ]) if (video && video.networkState !== 0 && !video.getAttribute("src")) video.load(); }); }, [items, isAttached]); const frame = useCallback( (now: number) => { const state = live.current; const seconds = state.last ? (now - state.last) / 1000 : 0; state.last = now; let moving = false; items.forEach((item, index) => { if (Math.abs(index - state.active) > 1) return; const before = state.smooth[index] ?? 0; const next = easeToward(before, state.target[index] ?? 0, seconds); state.smooth[index] = next; if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true; seekVideo(desktopVideos.current[index], next); seekVideo( phoneVideos.current[index], mobileFractionAt(item.config, next), ); const bar = bars.current[index]; if (bar) bar.style.setProperty("--p", next.toFixed(4)); }); const current = items[state.active]; if (current) { const index = chapterIndexAt( current.config, state.smooth[state.active] ?? 0, ); if (index !== state.chapter) { state.chapter = index; setChapter(index); } } state.raf = moving ? requestAnimationFrame(frame) : 0; }, [items], ); // um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está const syncVideos = useCallback( (index: number) => { const item = items[index]; const at = live.current.smooth[index] ?? live.current.target[index] ?? 0; seekVideo(desktopVideos.current[index], at); if (item) seekVideo( phoneVideos.current[index], mobileFractionAt(item.config, at), ); }, [items], ); const measure = useCallback(() => { const container = containerRef.current; const pin = pinRef.current; // escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar if (!container || !pin || items.length === 0 || !container.offsetHeight) return; const state = live.current; const rect = container.getBoundingClientRect(); const scrollable = rect.height - pin.offsetHeight; const progress = scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0; const { index, local } = locateInReel(weights, progress); state.target = items.map((_, i) => i < index ? 1 : i === index ? videoProgress(local) : 0, ); const near = rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight; if (near !== state.near) { state.near = near; setNear(near); } if (index !== state.active) { state.active = index; state.chapter = chapterIndexAt( items[index].config, state.smooth[index] ?? 0, ); setActive(index); setChapter(state.chapter); } if (state.near && !state.raf) { state.last = 0; state.raf = requestAnimationFrame(frame); } }, [frame, items, weights]); useEffect(() => { if (!mounted || reducedMotion || items.length === 0) return; let pending = 0; const schedule = () => { if (!pending) pending = requestAnimationFrame(() => { pending = 0; measure(); }); }; schedule(); window.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", schedule); const state = live.current; return () => { window.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); cancelAnimationFrame(pending); cancelAnimationFrame(state.raf); state.raf = 0; }; }, [mounted, reducedMotion, items.length, measure]); /** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */ const scrollToReel = (index: number, local: number) => { const container = containerRef.current; const pin = pinRef.current; if (!container || !pin) return; const scrollable = container.offsetHeight - pin.offsetHeight; const top = container.getBoundingClientRect().top + window.scrollY; // 2px além: garante que a conta caia dentro do trecho scrollToSmoothly( top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2, ); }; const goTo = (index: number) => scrollToReel(index, 0); const goToChapter = (index: number, chapterIndex: number) => { const fraction = chapterFraction( items[index].config, chapterIndex, "desktop", ); scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2)); }; if (items.length === 0 || (mounted && reducedMotion)) return null; // antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine // aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com // prefers-reduced-motion e em telas baixas if (!mounted) return (