"use client"; import { type CSSProperties, type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { chapterFraction, chapterIndexAt, getScrubSource, getShowcaseAssets, } from "@/lib/project-showcase"; import { BrowserFrame, displayUrl } from "./BrowserFrame"; import { PhoneFrame } from "./PhoneFrame"; import { ProjectActions } from "./ProjectActions"; import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider"; import { CAPTION_OUT_MS, easeToward, locateInReel, REEL_HOLD, reelOffset, videoProgress, } from "./showcase-math"; import { useRovingTabindex } from "./useRovingTabindex"; import { useSaveData } from "./useShowcaseMedia"; import "./pinned-reel.css"; // 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; }; /** 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); } const layerState = (index: number, active: number) => { if (index === active) return "active"; return index < active ? "before" : "after"; }; /** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */ const tickState = (tick: number, current: number) => { if (tick > current) return "todo"; return tick === current ? "now" : "done"; }; /** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */ function targetsAt(count: number, index: number, local: number) { return Array.from({ length: count }, (_, i) => { if (i < index) return 1; return i === index ? videoProgress(local) : 0; }); } /** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */ function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) { 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); // a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos const near = rect.top < window.innerHeight && rect.bottom > -window.innerHeight; return { index, local, near }; } /** * 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; o celular é só um * pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado). * * Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho, * contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro. * * Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela * da janela; com "economizar dados" ninguém baixa vídeo. */ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) { const openProject = useShowcaseOpen(); const [active, setActive] = useState(0); const [chapter, setChapter] = useState(0); const [near, setNear] = useState(false); const [settled, setSettled] = useState(0); const saveData = useSaveData(); const containerRef = useRef(null); const pinRef = useRef(null); const indexRef = useRef(null); const desktopVideos = 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); // só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); 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 && index === settled, [near, saveData, settled], ); // o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto const showsPoster = (index: number) => near && Math.abs(index - active) <= 1; // 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; const video = desktopVideos.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((_, index) => { if (Math.abs(index - state.active) > 1) return; const target = state.target[index] ?? 0; const next = easeToward(state.smooth[index] ?? 0, target, seconds); state.smooth[index] = next; if (Math.abs(next - target) > 0.0004) moving = true; seekVideo(desktopVideos.current[index], next); bars.current[index]?.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 syncVideo = useCallback((index: number) => { const state = live.current; seekVideo( desktopVideos.current[index], state.smooth[index] ?? state.target[index] ?? 0, ); }, []); /** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */ const changeActive = useCallback( (index: number) => { const state = live.current; state.active = index; state.chapter = chapterIndexAt( items[index].config, state.smooth[index] ?? 0, ); setActive(index); setChapter(state.chapter); }, [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?.offsetHeight || !pin) return; const state = live.current; const { index, local, near: isNear } = readReel(container, pin, weights); state.target = targetsAt(items.length, index, local); if (isNear !== state.near) { state.near = isNear; setNear(isNear); } if (index !== state.active) changeActive(index); if (state.near && !state.raf) { state.last = 0; state.raf = requestAnimationFrame(frame); } }, [changeActive, frame, items.length, weights]); useEffect(() => { 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; }; }, [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)); }; const current = items[active]; return (

Projetos de clientes

{items.map((item, index) => ( { desktopVideos.current[index] = element; }} onVideoLoaded={() => syncVideo(index)} /> ))}

{current ? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}` : ""}

); } /** * Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. É UMA parada * no Tab (roving tabindex): as setas e Home/End movem o foco entre os projetos e os trechos, Enter e espaço * escolhem. Sem isto eram um Tab por projeto e por trecho (7) antes de chegar aos botões da legenda. */ function ReelIndex({ items, active, chapter, listRef, bars, onProject, onChapter, }: { items: ShowcaseItem[]; active: number; chapter: number; listRef: RefObject; bars: RefObject>; onProject: (index: number) => void; onChapter: (index: number, chapter: number) => void; }) { const { groupProps, itemProps } = useRovingTabindex( `p:${active}`, // os trechos são sempre os do projeto em cena: os de um projeto que já saiu de cena deixam de ser a parada (key) => key.startsWith("p:") || key.startsWith(`c:${active}:`), ); return (