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:
Evandro Fonseca JuniorandClaude Sonnet 5.5 committed 2026-10-08 11:33:17 -03:00
1 parent ca9c0604b8
commit a67892613b
6 files changed
+276 -2

No files matched your search

+11
View File
@@ -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">
+131
View File
@@ -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);
}
},
);
});
+86
View File
@@ -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
View File
@@ -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);
}
}