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.
|
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
|
## Stack
|
||||||
|
|
||||||
| Área | Pacotes usados |
|
| Área | Pacotes usados |
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { getProjectVideo } from "@/lib/project-videos";
|
||||||
|
import { ProjectVideo } from "./ProjectVideo";
|
||||||
|
|
||||||
type CoverProject = { id: string; title: string };
|
type CoverProject = { id: string; title: string };
|
||||||
|
|
||||||
@@ -31,8 +33,17 @@ export function ProjectCover({
|
|||||||
project: CoverProject;
|
project: CoverProject;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const video = getProjectVideo(project.id);
|
||||||
const src = SCREENSHOTS[project.id];
|
const src = SCREENSHOTS[project.id];
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
return (
|
||||||
|
<div className="ds-cover">
|
||||||
|
<ProjectVideo video={video} title={project.title} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
return (
|
return (
|
||||||
<div className="ds-cover">
|
<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);
|
border: 1px solid var(--border);
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
|
|
||||||
& img {
|
& video {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img,
|
||||||
|
& video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -1292,7 +1297,8 @@ html.lenis {
|
|||||||
transition: transform 0.7s var(--ease-out);
|
transition: transform 0.7s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-card:hover & img {
|
.ds-card:hover & img,
|
||||||
|
.ds-card:hover & video {
|
||||||
transform: scale(1.035);
|
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