"use client"; import { type CSSProperties, type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { chapterFraction, chapterIndexAt, getScrubSource, getShowcaseAssets, } from "@/lib/project-showcase"; import { BrowserFrame, displayUrl } from "./BrowserFrame"; import { PhoneFrame } from "./PhoneFrame"; import { ProjectActions } from "./ProjectActions"; import { ProjectList } from "./ProjectList"; import { type ShowcaseItem, useShowcaseItems, useShowcaseOpen, } from "./ShowcaseProvider"; import { easeToward, locateInReel, REEL_HOLD, reelOffset, videoProgress, } from "./showcase-math"; import { usePinnedViewport, useSaveData, useViewportDevice, } from "./useShowcaseMedia"; // 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; }; /** Saída da legenda (ms) antes de a do novo projeto entrar: curta, para a troca nunca mostrar duas legendas. */ export const CAPTION_OUT_MS = 120; /** 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); } /** * Projetos da home. Em tablet e desktop (768 px de largura e 561 px de altura, no mínimo), a vitrine animada: * o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a página rola, com a * cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion * e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres. * O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá * um bloco da mesma altura segura o lugar dela. Funciona sozinha dentro de `ShowcaseProvider`: não depende * dos cards da grade. */ export function ProjectReel() { const items = useShowcaseItems(); const reducedMotion = useReducedMotion(); const wide = usePinnedViewport(); const device = useViewportDevice(); if (items.length === 0) return null; const mounted = wide !== null && device !== null; if (mounted && wide && !reducedMotion) return ; return ( <> {!mounted && ( // antes de montar, um bloco da mesma altura segura o lugar da vitrine fixa: a página não muda de // tamanho quando ela aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS só o // mostra onde a vitrine fixa vai existir (e a lista, nesses casos, fica escondida)