Files
portifolio-evandrodevbr/app/components/showcase/ProjectReel.tsx
T
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00

581 lines
21 KiB
TypeScript

"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 { 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;
};
/** 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 <PinnedReel items={items} />;
return (
<>
<ProjectList items={items} device={device} pending={!mounted} />
{!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)
<div
className="ds-reel ds-reel-placeholder"
aria-hidden="true"
style={
{
"--reel-sum": items.reduce(
(sum, item) => sum + item.config.weight,
0,
),
} as CSSProperties
}
/>
)}
</>
);
}
/**
* 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.
*
* 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. O vídeo de celular só é pedido se a moldura do
* celular está à vista.
*/
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 [phoneVisible, setPhoneVisible] = useState(true);
const saveData = useSaveData();
const containerRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLDivElement>(null);
const indexRef = useRef<HTMLOListElement>(null);
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
const bars = useRef<Array<HTMLElement | null>>([]);
const live = useRef({
target: [] as number[],
smooth: [] as number[],
active: 0,
chapter: 0,
raf: 0,
last: 0,
near: false,
phone: true,
});
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;
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,
);
// 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;
if (near !== state.near) {
state.near = near;
setNear(near);
}
// o celular da moldura só baixa vídeo se está à vista (tem tamanho na tela)
const phone = container.querySelector<HTMLElement>(".ds-reel-phone");
const phoneShown = Boolean(phone && phone.offsetWidth > 0);
if (phoneShown !== state.phone) {
state.phone = phoneShown;
setPhoneVisible(phoneShown);
}
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(() => {
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 (
<section
className="ds-reel"
aria-label="Vitrine animada dos projetos"
style={{ "--reel-sum": weightSum } as CSSProperties}
ref={containerRef}
>
<h2 className="sr-only">Projetos de clientes</h2>
<div className="ds-reel-pin" ref={pinRef}>
<div className="ds-reel-grid">
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
<p className="ds-reel-hint">
<span aria-hidden="true" className="ds-reel-hint-line" />
Em movimento
<span className="ds-reel-hint-sub">Role para avançar</span>
</p>
<ol ref={indexRef}>
{items.map((item, index) => (
<li key={item.config.id}>
<button
type="button"
aria-current={index === active ? "true" : undefined}
aria-label={item.project.title}
onClick={() => goTo(index)}
>
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
<span
className="ds-reel-num"
aria-hidden="true"
data-n={String(index + 1).padStart(2, "0")}
/>
<span className="ds-reel-name">{item.project.title}</span>
<i
className="ds-reel-bar"
aria-hidden="true"
ref={(element) => {
bars.current[index] = element;
}}
/>
</button>
{index === active && (
<ol
className="ds-reel-chapters"
aria-label={`Trechos de ${item.project.title}`}
>
{item.config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
aria-current={i === chapter ? "true" : undefined}
onClick={() => goToChapter(index, i)}
>
<span aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
{entry.label}
</button>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
<div className="ds-reel-stage">
{items.map((item, index) => {
const { config, project } = item;
const isActive = index === active;
const attach = isAttached(index);
const posters = showsPoster(index);
const desktop = getShowcaseAssets(config.id, "desktop");
const phone = getShowcaseAssets(config.id, "mobile");
return (
<div
key={config.id}
className="ds-reel-layer"
data-state={
isActive ? "active" : index < active ? "before" : "after"
}
style={{ "--accent": config.accent } as CSSProperties}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
<div className="ds-reel-set">
<div className="ds-reel-devices">
<BrowserFrame
url={displayUrl(project)}
className="ds-reel-laptop"
>
<div
className="ds-reel-screen"
style={
posters
? { backgroundImage: `url(${desktop.poster})` }
: undefined
}
>
<video
ref={(element) => {
desktopVideos.current[index] = element;
}}
src={
attach
? getScrubSource(config.id, "desktop")
: undefined
}
poster={posters ? desktop.poster : undefined}
muted
playsInline
preload="metadata"
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</div>
</BrowserFrame>
<PhoneFrame
className="ds-reel-phone"
poster={posters ? phone.poster : undefined}
>
<video
ref={(element) => {
phoneVideos.current[index] = element;
}}
src={
attach && phoneVisible
? getScrubSource(config.id, "mobile")
: undefined
}
poster={posters ? phone.poster : undefined}
muted
playsInline
preload="metadata"
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</PhoneFrame>
</div>
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && (
<p className="ds-reel-sub">{project.subtitle}</p>
)}
<div className="ds-reel-chapter">
<span className="ds-reel-chapter-count">
{String((isActive ? chapter : 0) + 1).padStart(
2,
"0",
)}
/{String(config.chapters.length).padStart(2, "0")}
</span>
<strong>
{config.chapters[isActive ? chapter : 0].label}
</strong>
<ol className="ds-reel-ticks" aria-hidden="true">
{config.chapters.map((entry, tick) => (
<li
key={entry.label}
data-state={
!isActive || tick > chapter
? "todo"
: tick === chapter
? "now"
: "done"
}
/>
))}
</ol>
</div>
<ProjectActions
item={item}
onExpand={() => openProject(config.id)}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
<p className="sr-only" aria-live="polite">
{current
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
: ""}
</p>
</div>
</section>
);
}