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
+15 -5
View File
@@ -10,7 +10,8 @@ import {
DetailCustomizeMenu,
Filterable,
PrefToggle,
useArtBackdrop,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor";
@@ -277,7 +278,8 @@ export default function PlexInfo({
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -296,9 +298,15 @@ export default function PlexInfo({
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
// Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
// fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
useArtBackdrop(detail.art, variant !== "overlay" && artBg);
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
// variant / when disabled / on unmount.
const backdropPool = [
detail.art,
...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []),
];
useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade);
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => {
@@ -346,6 +354,8 @@ export default function PlexInfo({
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}