perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
chapterIndexAt,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
@@ -54,6 +54,9 @@ const headerHeight = () => {
|
||||
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;
|
||||
@@ -147,15 +150,49 @@ export function ProjectReel() {
|
||||
);
|
||||
}
|
||||
|
||||
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; notebook e celular
|
||||
* mostram o mesmo trecho do site ao mesmo tempo.
|
||||
* 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. O vídeo de celular só é pedido se a moldura do
|
||||
* celular está à vista.
|
||||
* da janela; com "economizar dados" ninguém baixa vídeo.
|
||||
*/
|
||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
@@ -163,14 +200,12 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
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[],
|
||||
@@ -180,7 +215,6 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
phone: true,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
@@ -224,12 +258,9 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
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();
|
||||
const video = desktopVideos.current[index];
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
});
|
||||
}, [items, isAttached]);
|
||||
|
||||
@@ -239,19 +270,14 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
||||
state.last = now;
|
||||
let moving = false;
|
||||
items.forEach((item, index) => {
|
||||
items.forEach((_, index) => {
|
||||
if (Math.abs(index - state.active) > 1) return;
|
||||
const before = state.smooth[index] ?? 0;
|
||||
const next = easeToward(before, state.target[index] ?? 0, seconds);
|
||||
const target = state.target[index] ?? 0;
|
||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
|
||||
if (Math.abs(next - target) > 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));
|
||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
@@ -270,16 +296,25 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideos = useCallback(
|
||||
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 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),
|
||||
);
|
||||
const state = live.current;
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
@@ -288,46 +323,20 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
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;
|
||||
if (!container?.offsetHeight || !pin) 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);
|
||||
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);
|
||||
}
|
||||
}, [frame, items, weights]);
|
||||
}, [changeActive, frame, items.length, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
let pending = 0;
|
||||
@@ -386,187 +395,41 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
<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>
|
||||
<ReelIndex
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
listRef={indexRef}
|
||||
bars={bars}
|
||||
onProject={goTo}
|
||||
onChapter={goToChapter}
|
||||
/>
|
||||
|
||||
<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-set">
|
||||
<div className="ds-reel-devices">
|
||||
{items.map((item, index) => (
|
||||
<ReelLayer
|
||||
key={item.config.id}
|
||||
item={item}
|
||||
state={layerState(index, active)}
|
||||
poster={showsPoster(index)}
|
||||
attached={isAttached(index)}
|
||||
videoRef={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
onVideoLoaded={() => syncVideo(index)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
<ReelCaption
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
onExpand={openProject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
@@ -578,3 +441,217 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. */
|
||||
function ReelIndex({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
listRef,
|
||||
bars,
|
||||
onProject,
|
||||
onChapter,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
listRef: RefObject<HTMLOListElement | null>;
|
||||
bars: RefObject<Array<HTMLElement | null>>;
|
||||
onProject: (index: number) => void;
|
||||
onChapter: (index: number, chapter: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<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={listRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-reel-pick"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => onProject(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"
|
||||
className="ds-reel-chapter-pick"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => onChapter(index, i)}
|
||||
>
|
||||
<span className="ds-reel-chapter-num" aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
|
||||
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
|
||||
*/
|
||||
function ReelLayer({
|
||||
item,
|
||||
state,
|
||||
poster,
|
||||
attached,
|
||||
videoRef,
|
||||
onVideoLoaded,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
state: "active" | "before" | "after";
|
||||
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
|
||||
poster: boolean;
|
||||
/** O vídeo de rolagem já pode ser baixado. */
|
||||
attached: boolean;
|
||||
videoRef: (element: HTMLVideoElement | null) => void;
|
||||
onVideoLoaded: () => void;
|
||||
}) {
|
||||
const { config, project } = item;
|
||||
const isActive = state === "active";
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
className="ds-reel-layer"
|
||||
data-state={state}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attached ? getScrubSource(config.id) : undefined}
|
||||
poster={poster ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={onVideoLoaded}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={poster ? phone.poster : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
|
||||
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
|
||||
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
|
||||
*/
|
||||
function ReelCaption({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
onExpand,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
onExpand: (id: string) => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState({ index: active, chapter });
|
||||
const leaving = shown.index !== active;
|
||||
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
|
||||
const latest = useRef({ active, chapter });
|
||||
useEffect(() => {
|
||||
latest.current = { active, chapter };
|
||||
});
|
||||
|
||||
// dentro do mesmo projeto o trecho troca no lugar
|
||||
useEffect(() => {
|
||||
if (shown.index === active && shown.chapter !== chapter)
|
||||
setShown({ index: active, chapter });
|
||||
}, [active, chapter, shown]);
|
||||
|
||||
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
|
||||
useEffect(() => {
|
||||
if (shown.index === active) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
setShown({
|
||||
index: latest.current.active,
|
||||
chapter: latest.current.chapter,
|
||||
}),
|
||||
CAPTION_OUT_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shown.index]);
|
||||
|
||||
const item = items[shown.index];
|
||||
const { config, project } = item;
|
||||
return (
|
||||
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
||||
<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">
|
||||
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
|
||||
</span>
|
||||
<strong>{config.chapters[shown.chapter].label}</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user