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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user