E5 S8: crossfading art backdrop (detail + person pages)

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).
This commit is contained in:
2026-08-21 01:00:11 +02:00
parent d56a791c04
commit 7d40a8780d
6 changed files with 284 additions and 41 deletions
+40 -5
View File
@@ -15,7 +15,8 @@ import {
DetailHero,
epLabel,
Filterable,
useArtBackdrop,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
WatchedToggle,
} from "../lib/plexDetailUi";
@@ -107,6 +108,15 @@ export function PlexPersonView({
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
const pd = q.data;
useCrossfadeBackdrop(
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
artBg,
crossfade,
);
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
@@ -298,8 +308,25 @@ export function PlexShowView({
const showLib = show?.library ?? undefined;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
useArtBackdrop(show?.art, artBg);
const {
artBg,
showCast,
showRelated,
crossfade,
toggleArtBg,
toggleCast,
toggleRelated,
toggleCrossfade,
} = useDetailPrefs();
useCrossfadeBackdrop(
[
show?.art,
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(d?.related ?? []).map((r) => r.art),
],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
@@ -345,6 +372,8 @@ export function PlexShowView({
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
@@ -541,8 +570,12 @@ export function PlexSeasonView({
// seasonKeys stays only for the season-scoped "add to playlist" action.
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
const watched = se?.status === "watched";
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
useArtBackdrop(d?.show.art, artBg);
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
useCrossfadeBackdrop(
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
@@ -588,6 +621,8 @@ export function PlexSeasonView({
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={false}