feat(projects): video de rolagem na capa dos cards, carregado so quando o card aparece
video autoplay muted loop playsinline preload=none com poster; as fontes entram via IntersectionObserver e o video pausa fora da tela. Com prefers-reduced-motion aparece so o poster. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
ca9c0604b8
commit
a67892613b
6 files changed
+276
-2
No files matched your search
@@ -47,6 +47,12 @@ O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHu
|
||||
|
||||
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
|
||||
|
||||
## Vídeos dos projetos
|
||||
|
||||
Cada card de projeto da home mostra um vídeo curto, em loop e sem áudio, da página rolando em velocidade constante. Os arquivos ficam em `public/videos/<id do projeto>/` (`desktop` em WebM, MP4 e poster WebP, até 3 MB cada vídeo, mais a versão `mobile`) e são ligados aos cards em `lib/project-videos.ts`.
|
||||
|
||||
Em `app/components/ProjectVideo.tsx`, o vídeo só começa a ser baixado quando o card entra na tela (IntersectionObserver), pausa ao sair, e quem tem `prefers-reduced-motion` vê só o poster. Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho do script).
|
||||
|
||||
## Stack
|
||||
|
||||
| Área | Pacotes usados |
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { getProjectVideo } from "@/lib/project-videos";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
type CoverProject = { id: string; title: string };
|
||||
|
||||
@@ -31,8 +33,17 @@ export function ProjectCover({
|
||||
project: CoverProject;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const video = getProjectVideo(project.id);
|
||||
const src = SCREENSHOTS[project.id];
|
||||
|
||||
if (video) {
|
||||
return (
|
||||
<div className="ds-cover">
|
||||
<ProjectVideo video={video} title={project.title} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<div className="ds-cover">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
getProjectVideo,
|
||||
PROJECT_VIDEO_IDS,
|
||||
type ProjectVideoAsset,
|
||||
} from "@/lib/project-videos";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const video: ProjectVideoAsset = {
|
||||
poster: "/videos/demo/desktop.webp",
|
||||
width: 1440,
|
||||
height: 900,
|
||||
sources: [
|
||||
{ src: "/videos/demo/desktop.webm", type: 'video/webm; codecs="vp9"' },
|
||||
{ src: "/videos/demo/desktop.mp4", type: "video/mp4" },
|
||||
],
|
||||
};
|
||||
|
||||
type Callback = IntersectionObserverCallback;
|
||||
let callbacks: Callback[] = [];
|
||||
const originalObserver = global.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
callbacks = [];
|
||||
// observador controlável: o teste decide quando o card entra e sai da tela
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback: Callback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = originalObserver;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setVisible(isIntersecting: boolean) {
|
||||
act(() => {
|
||||
for (const callback of callbacks) {
|
||||
callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectVideo", () => {
|
||||
it("renders a muted looping inline video that downloads nothing before it is visible", () => {
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
const element = container.querySelector("video");
|
||||
|
||||
expect(element).not.toBeNull();
|
||||
expect(element).toHaveAttribute("autoplay");
|
||||
expect(element).toHaveAttribute("loop");
|
||||
expect(element).toHaveAttribute("playsinline");
|
||||
expect(element).toHaveAttribute("preload", "none");
|
||||
expect(element).toHaveAttribute("poster", video.poster);
|
||||
expect(element?.muted).toBe(true);
|
||||
expect(container.querySelectorAll("source")).toHaveLength(0);
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => {
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
|
||||
setVisible(true);
|
||||
const sources = [...container.querySelectorAll("source")];
|
||||
expect(sources.map((source) => source.getAttribute("src"))).toEqual([
|
||||
"/videos/demo/desktop.webm",
|
||||
"/videos/demo/desktop.mp4",
|
||||
]);
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||
|
||||
setVisible(false);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Captura de tela de Demo" }),
|
||||
).toBeInTheDocument();
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectVideo", () => {
|
||||
it("returns undefined for projects without a recorded video", () => {
|
||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(PROJECT_VIDEO_IDS)(
|
||||
"has webm, mp4 and poster on disk for %s, each within 3 MB",
|
||||
(id) => {
|
||||
const asset = getProjectVideo(id);
|
||||
expect(asset).toBeDefined();
|
||||
const files = [
|
||||
asset?.poster,
|
||||
...(asset?.sources.map((s) => s.src) ?? []),
|
||||
];
|
||||
for (const file of files) {
|
||||
const path = join(process.cwd(), "public", file ?? "");
|
||||
expect(existsSync(path)).toBe(true);
|
||||
expect(statSync(path).size).toBeLessThanOrEqual(3 * 1024 * 1024);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { ProjectVideoAsset } from "@/lib/project-videos";
|
||||
|
||||
/**
|
||||
* Vídeo de rolagem do projeto na capa do card. Nada de vídeo é baixado até o card entrar na tela
|
||||
* (IntersectionObserver); fora da tela ele pausa. Com prefers-reduced-motion só o poster aparece.
|
||||
*/
|
||||
export function ProjectVideo({
|
||||
video,
|
||||
title,
|
||||
}: {
|
||||
video: ProjectVideoAsset;
|
||||
title: string;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [activated, setActivated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (reducedMotion || !element) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setActivated(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setActivated(true);
|
||||
element.play().catch(() => {});
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "120px 0px", threshold: 0.2 },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!activated || reducedMotion || !element) return;
|
||||
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
|
||||
element.load();
|
||||
element.play().catch(() => {});
|
||||
}, [activated, reducedMotion]);
|
||||
|
||||
if (reducedMotion) {
|
||||
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="none"
|
||||
poster={video.poster}
|
||||
width={video.width}
|
||||
height={video.height}
|
||||
aria-label={`Demonstração em vídeo de ${title}`}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
>
|
||||
{activated &&
|
||||
video.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
+8
-2
@@ -1284,7 +1284,12 @@ html.lenis {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--ink);
|
||||
|
||||
& img {
|
||||
& video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& img,
|
||||
& video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -1292,7 +1297,8 @@ html.lenis {
|
||||
transition: transform 0.7s var(--ease-out);
|
||||
}
|
||||
|
||||
.ds-card:hover & img {
|
||||
.ds-card:hover & img,
|
||||
.ds-card:hover & video {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Vídeos de rolagem dos projetos: public/videos/<id>/desktop.{webm,mp4,webp}.
|
||||
* Gravados com scripts/record-project-videos.mjs; só entram aqui os ids que têm os três arquivos.
|
||||
*/
|
||||
export interface ProjectVideoAsset {
|
||||
poster: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sources: Array<{ src: string; type: string }>;
|
||||
}
|
||||
|
||||
export const PROJECT_VIDEO_IDS = [
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
] as const;
|
||||
|
||||
const recorded = new Set<string>(PROJECT_VIDEO_IDS);
|
||||
|
||||
export function getProjectVideo(id: string): ProjectVideoAsset | undefined {
|
||||
if (!recorded.has(id)) return undefined;
|
||||
const base = `/videos/${id}/desktop`;
|
||||
return {
|
||||
poster: `${base}.webp`,
|
||||
width: 1440,
|
||||
height: 900,
|
||||
sources: [
|
||||
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
|
||||
{ src: `${base}.mp4`, type: "video/mp4" },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user