From 7d40a8780dd802ede48f2c4d2bcd400774040085 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 21 Aug 2026 01:00:11 +0200 Subject: [PATCH] E5 S8: crossfading art backdrop (detail + person pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/components/PlexInfo.tsx | 20 ++- frontend/src/components/plexViews.tsx | 45 +++++- frontend/src/i18n/locales/en/plex.json | 3 +- frontend/src/i18n/locales/hu/plex.json | 3 +- frontend/src/lib/plexBackdrop.dom.test.tsx | 103 ++++++++++++++ frontend/src/lib/plexDetailUi.tsx | 151 +++++++++++++++++---- 6 files changed, 284 insertions(+), 41 deletions(-) create mode 100644 frontend/src/lib/plexBackdrop.dom.test.tsx diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 0c5e6f8c..23f8b5d6 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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 }>(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
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} diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index ba3dc8ed..d553faff 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -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({ 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({ ({ + 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(); + 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(); + 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(); + 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(); + 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(); + expect(root().style.backgroundImage).toBe(""); + expect(overlays().length).toBe(0); + rerender(); + expect(overlays().length).toBe(1); + unmount(); + expect(overlays().length).toBe(0); // cleaned up + expect(root().style.isolation).toBe(""); + }); +}); diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index cf4b0199..4582d47b 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -24,18 +24,30 @@ export function useDetailPrefs() { const [artBg, setArtBg] = usePref("plexInfoArtBg", true); const [showCast, setShowCast] = usePref("plexInfoCast", true); const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); + // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent + // of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle. + const [crossfade, setCrossfade] = usePref("plexInfoArtCrossfade", true); const savePref = useSavePref(); return { artBg, showCast, showRelated, + crossfade, savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) toggleArtBg: () => setArtBg(!artBg), toggleCast: () => setShowCast(!showCast), toggleRelated: () => setShowRelated(!showRelated), + toggleCrossfade: () => setCrossfade(!crossfade), }; } +// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only +// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the +// crossfade even on a lone movie with no collection. +export function tmdbBackdropUrl(path: string): string { + return `https://image.tmdb.org/t/p/w1280${path}`; +} + // A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared // by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional // `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it. @@ -62,39 +74,107 @@ export function Filterable({ ); } -// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over -// it. Painted on the app root (#root) — which is exactly the viewport and sits BEHIND the transparent -// nav / header / content — so the art fills the whole page edge-to-edge (under the rail + floating -// header too), not just the inset
box. (A `position:fixed` layer at negative z-index does NOT -// work here: the body's opaque background is painted over it, so it's invisible; #root's background, -// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed` -// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art. -export function useArtBackdrop(art: string | null | undefined, enabled: boolean) { +// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8). +// +// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport +// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav +// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is +// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS +// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the +// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to +// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node. +// +// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and +// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored +// while the content scrolls. Cleared on unmount / disable / empty pool. +const BACKDROP_FADE_MS = 1600; +const BACKDROP_INTERVAL_MS = 7000; +const BACKDROP_DIM = + "linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " + + "color-mix(in srgb, var(--bg) 64%, transparent))"; + +export function useCrossfadeBackdrop( + arts: (string | null | undefined)[], + enabled: boolean, + animate = true, +) { + // Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change. + const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|"); useLayoutEffect(() => { - const el = document.getElementById("root"); - if (!el) return; - const s = el.style; - const clear = () => { - s.backgroundImage = ""; - s.backgroundSize = ""; - s.backgroundPosition = ""; - s.backgroundRepeat = ""; - s.backgroundAttachment = ""; + const root = document.getElementById("root"); + if (!root) return; + const paint = (url: string) => { + root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`; + root.style.backgroundSize = "cover"; + root.style.backgroundPosition = "center 20%"; + root.style.backgroundRepeat = "no-repeat"; + root.style.backgroundAttachment = "fixed"; + // #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's + // own background (the settled art) rather than behind it — without this the overlay is invisible. + root.style.isolation = "isolate"; }; - if (enabled && art) { - // The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used). - s.backgroundImage = - `linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` + - `color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`; - s.backgroundSize = "cover"; - s.backgroundPosition = "center 20%"; - s.backgroundRepeat = "no-repeat"; - s.backgroundAttachment = "fixed"; - } else { + const clear = () => { + root.style.backgroundImage = ""; + root.style.backgroundSize = ""; + root.style.backgroundPosition = ""; + root.style.backgroundRepeat = ""; + root.style.backgroundAttachment = ""; + root.style.isolation = ""; + }; + + const pool = key ? key.split("|") : []; + if (!enabled || pool.length === 0) { clear(); + return; } - return clear; - }, [art, enabled]); + paint(pool[0]!); + + const reduce = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!animate || reduce || pool.length < 2) return; // static: first image only + + const layer = document.createElement("div"); + Object.assign(layer.style, { + position: "fixed", + inset: "0", + zIndex: "-1", + backgroundSize: "cover", + backgroundPosition: "center 20%", + backgroundRepeat: "no-repeat", + backgroundAttachment: "fixed", + opacity: "0", + pointerEvents: "none", + }); + root.appendChild(layer); + + let idx = 0; + let fadeTimer: ReturnType | undefined; + const tick = () => { + const next = (idx + 1) % pool.length; + layer.style.transition = "none"; + layer.style.opacity = "0"; + layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`; + requestAnimationFrame(() => { + layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`; + layer.style.opacity = "1"; + }); + fadeTimer = setTimeout(() => { + paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change) + layer.style.transition = "none"; + layer.style.opacity = "0"; + idx = next; + }, BACKDROP_FADE_MS); + }; + const interval = setInterval(tick, BACKDROP_INTERVAL_MS); + return () => { + clearInterval(interval); + if (fadeTimer) clearTimeout(fadeTimer); + layer.remove(); + clear(); + }; + }, [key, enabled, animate]); } export function DetailCustomizeMenu({ @@ -106,6 +186,8 @@ export function DetailCustomizeMenu({ showRelated, onToggleRelated, hasRelated, + crossfade, + onToggleCrossfade, overlay, extra, }: { @@ -117,6 +199,10 @@ export function DetailCustomizeMenu({ showRelated?: boolean; onToggleRelated?: () => void; hasRelated?: boolean; + // Crossfade toggle (E5 S8) — offered only when the art backdrop itself is on; omitted where a page + // doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control). + crossfade?: boolean; + onToggleCrossfade?: () => void; overlay?: boolean; // white-on-dark button styling when floated over the player extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets) }) { @@ -148,6 +234,13 @@ export function DetailCustomizeMenu({ style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }} > + {artBg && onToggleCrossfade && ( + + )} {hasCast && ( )}