Files
portifolio-evandrodevbr/app/components/ProjectVideo.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

94 lines
3.0 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ShowcaseAssets } from "@/lib/project-showcase";
import { useSaveData } from "./showcase/useShowcaseMedia";
/**
* Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de
* 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está
* sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card
* fica ativo (`preload="metadata"`, sem fontes até lá); fora de cena ele pausa. Com prefers-reduced-motion ou
* "economizar dados" só o pôster aparece. `showPoster` falso (seção longe da tela) não pede nem o pôster.
* O pôster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
*/
export function ProjectVideo({
video,
title,
active,
showPoster = true,
}: {
video: ShowcaseAssets;
title: string;
active: boolean;
showPoster?: boolean;
}) {
const reducedMotion = useReducedMotion();
const saveData = useSaveData();
const ref = useRef<HTMLVideoElement>(null);
const [attached, setAttached] = useState(false);
const [ready, setReady] = useState(false);
const posterOnly = reducedMotion || saveData;
const playing = active && !posterOnly;
const firstSource = video.sources[0]?.src;
useEffect(() => {
if (playing) setAttached(true);
}, [playing]);
useEffect(() => {
// as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova
if (attached && firstSource) ref.current?.load();
}, [attached, firstSource]);
useEffect(() => {
const element = ref.current;
if (!element || !attached) return;
if (playing) {
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
element.play().catch(() => {});
} else {
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
}
}, [attached, playing]);
if (posterOnly) {
if (!showPoster) return null;
return (
<Image
src={video.poster}
alt={`Captura de tela de ${title}`}
width={video.width}
height={video.height}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
/>
);
}
return (
<video
ref={ref}
autoPlay
muted
loop
playsInline
preload="metadata"
poster={showPoster ? video.poster : undefined}
width={video.width}
height={video.height}
data-ready={ready}
onPlaying={() => setReady(true)}
aria-label={`Demonstração em vídeo de ${title}`}
disablePictureInPicture
disableRemotePlayback
>
{attached &&
video.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}