The faint HTPC art backdrop now slowly cross-dissolves through a pool of images
instead of showing one static art — the last E5 sprint.
- useCrossfadeBackdrop (replaces useArtBackdrop): a double buffer — the SETTLED
image lives on #root's background (the proven full-viewport layer); the next
image cross-fades in on a z-index:-1 overlay layer, then is committed and the
overlay reset. ⭐ #root is given `isolation: isolate` so it's a stacking context
— without it the z:-1 overlay paints BEHIND #root's own background and is
invisible (found + fixed live: a red test layer + a real TMDB backdrop both
only showed once #root was isolated).
- Art pool: the item's own art + its TMDB backdrops (E5 S2, rendered straight
from the TMDB CDN — the CSP allows it) + its collection siblings' art; the show
page adds related art; the PERSON page cross-dissolves through the person's
films' art (the roadmap's signature touch).
- HARD-OFF under prefers-reduced-motion (static, no overlay) + a new usePref
"Crossfade backdrop" toggle in the detail customize menu (fade 1.6s / 7s cycle).
Wired into PlexInfo, PlexShowView, PlexSeasonView, PlexPersonView. Tests:
plexBackdrop.dom.test.tsx (5: paint+isolate+overlay, reduced-motion off, single
image, animate=false, disable/unmount cleanup). Browser-E2E'd (isolation +
overlay render + a real backdrop crossfade + the toggle). Gate: vitest 347,
backend lane 403 (unchanged — FE only).
▶ E5 · Plex Experience epic COMPLETE (S1–S8).
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
|
|
// pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background),
|
|
// creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first
|
|
// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed
|
|
// per-test; the hook reads it at effect time.
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { render, cleanup } from "@testing-library/react";
|
|
import { useCrossfadeBackdrop } from "./plexDetailUi";
|
|
|
|
function mockReducedMotion(reduce: boolean) {
|
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches: query.includes("prefers-reduced-motion") ? reduce : false,
|
|
media: query,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
dispatchEvent: () => false,
|
|
})) as unknown as typeof window.matchMedia;
|
|
}
|
|
|
|
function Harness({
|
|
arts,
|
|
enabled = true,
|
|
animate = true,
|
|
}: {
|
|
arts: (string | null | undefined)[];
|
|
enabled?: boolean;
|
|
animate?: boolean;
|
|
}) {
|
|
useCrossfadeBackdrop(arts, enabled, animate);
|
|
return null;
|
|
}
|
|
|
|
function root() {
|
|
return document.getElementById("root")!;
|
|
}
|
|
|
|
// The hook paints #root, so it must exist in the document (main.tsx renders into it in the real app).
|
|
function withRoot() {
|
|
const el = document.createElement("div");
|
|
el.id = "root";
|
|
document.body.appendChild(el);
|
|
return el;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
document.getElementById("root")?.remove();
|
|
});
|
|
|
|
const overlays = () =>
|
|
Array.from(root().children).filter((c) => (c as HTMLElement).style.zIndex === "-1");
|
|
|
|
describe("useCrossfadeBackdrop", () => {
|
|
it("paints + isolates #root and creates one overlay when it can animate", () => {
|
|
withRoot();
|
|
mockReducedMotion(false);
|
|
render(<Harness arts={["/a.jpg", "/b.jpg", "/c.jpg"]} />);
|
|
expect(root().style.backgroundImage).toContain('url("/a.jpg")');
|
|
expect(root().style.isolation).toBe("isolate"); // needed for the z-index:-1 overlay to show
|
|
expect(overlays().length).toBe(1);
|
|
});
|
|
|
|
it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => {
|
|
withRoot();
|
|
mockReducedMotion(true);
|
|
render(<Harness arts={["/a.jpg", "/b.jpg"]} />);
|
|
expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted
|
|
expect(overlays().length).toBe(0); // but no crossfade layer
|
|
});
|
|
|
|
it("stays static for a single image (no overlay)", () => {
|
|
withRoot();
|
|
mockReducedMotion(false);
|
|
render(<Harness arts={["/only.jpg", null, undefined]} />);
|
|
expect(root().style.backgroundImage).toContain('url("/only.jpg")');
|
|
expect(overlays().length).toBe(0);
|
|
});
|
|
|
|
it("does not animate when animate=false (crossfade pref off)", () => {
|
|
withRoot();
|
|
mockReducedMotion(false);
|
|
render(<Harness arts={["/a.jpg", "/b.jpg"]} animate={false} />);
|
|
expect(root().style.backgroundImage).toContain('url("/a.jpg")');
|
|
expect(overlays().length).toBe(0);
|
|
});
|
|
|
|
it("clears #root when disabled and removes the overlay on unmount", () => {
|
|
withRoot();
|
|
mockReducedMotion(false);
|
|
const { rerender, unmount } = render(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={false} />);
|
|
expect(root().style.backgroundImage).toBe("");
|
|
expect(overlays().length).toBe(0);
|
|
rerender(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={true} />);
|
|
expect(overlays().length).toBe(1);
|
|
unmount();
|
|
expect(overlays().length).toBe(0); // cleaned up
|
|
expect(root().style.isolation).toBe("");
|
|
});
|
|
});
|