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,
|
DetailCustomizeMenu,
|
||||||
Filterable,
|
Filterable,
|
||||||
PrefToggle,
|
PrefToggle,
|
||||||
useArtBackdrop,
|
tmdbBackdropUrl,
|
||||||
|
useCrossfadeBackdrop,
|
||||||
useDetailPrefs,
|
useDetailPrefs,
|
||||||
} from "../lib/plexDetailUi";
|
} from "../lib/plexDetailUi";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
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:
|
// 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
|
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
|
||||||
// is PlexInfo-specific state, persisted through the same savePref.
|
// 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 ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||||
{}) as { plexHiddenStripSources?: string[] };
|
{}) as { plexHiddenStripSources?: string[] };
|
||||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
// 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 [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [playlistOpen, setPlaylistOpen] = 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
|
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
|
||||||
// fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
|
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
|
||||||
useArtBackdrop(detail.art, variant !== "overlay" && artBg);
|
// 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 inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||||
const setState = async (status: "new" | "watched") => {
|
const setState = async (status: "new" | "watched") => {
|
||||||
@@ -346,6 +354,8 @@ export default function PlexInfo({
|
|||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
artBg={artBg}
|
artBg={artBg}
|
||||||
onToggleArtBg={toggleArtBg}
|
onToggleArtBg={toggleArtBg}
|
||||||
|
crossfade={crossfade}
|
||||||
|
onToggleCrossfade={toggleCrossfade}
|
||||||
showCast={showCast}
|
showCast={showCast}
|
||||||
onToggleCast={toggleCast}
|
onToggleCast={toggleCast}
|
||||||
hasCast={detail.cast.length > 0}
|
hasCast={detail.cast.length > 0}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
DetailHero,
|
DetailHero,
|
||||||
epLabel,
|
epLabel,
|
||||||
Filterable,
|
Filterable,
|
||||||
useArtBackdrop,
|
tmdbBackdropUrl,
|
||||||
|
useCrossfadeBackdrop,
|
||||||
useDetailPrefs,
|
useDetailPrefs,
|
||||||
WatchedToggle,
|
WatchedToggle,
|
||||||
} from "../lib/plexDetailUi";
|
} from "../lib/plexDetailUi";
|
||||||
@@ -107,6 +108,15 @@ export function PlexPersonView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
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
|
// 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
|
// (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 showLib = show?.library ?? undefined;
|
||||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||||
const watched = show?.status === "watched";
|
const watched = show?.status === "watched";
|
||||||
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
|
const {
|
||||||
useArtBackdrop(show?.art, artBg);
|
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) {
|
async function markAll(w: boolean) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -345,6 +372,8 @@ export function PlexShowView({
|
|||||||
<DetailCustomizeMenu
|
<DetailCustomizeMenu
|
||||||
artBg={artBg}
|
artBg={artBg}
|
||||||
onToggleArtBg={toggleArtBg}
|
onToggleArtBg={toggleArtBg}
|
||||||
|
crossfade={crossfade}
|
||||||
|
onToggleCrossfade={toggleCrossfade}
|
||||||
showCast={showCast}
|
showCast={showCast}
|
||||||
onToggleCast={toggleCast}
|
onToggleCast={toggleCast}
|
||||||
hasCast={show.cast.length > 0}
|
hasCast={show.cast.length > 0}
|
||||||
@@ -541,8 +570,12 @@ export function PlexSeasonView({
|
|||||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||||
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||||
const watched = se?.status === "watched";
|
const watched = se?.status === "watched";
|
||||||
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
|
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||||
useArtBackdrop(d?.show.art, artBg);
|
useCrossfadeBackdrop(
|
||||||
|
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
|
||||||
|
artBg,
|
||||||
|
crossfade,
|
||||||
|
);
|
||||||
async function markAll(w: boolean) {
|
async function markAll(w: boolean) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -588,6 +621,8 @@ export function PlexSeasonView({
|
|||||||
<DetailCustomizeMenu
|
<DetailCustomizeMenu
|
||||||
artBg={artBg}
|
artBg={artBg}
|
||||||
onToggleArtBg={toggleArtBg}
|
onToggleArtBg={toggleArtBg}
|
||||||
|
crossfade={crossfade}
|
||||||
|
onToggleCrossfade={toggleCrossfade}
|
||||||
showCast={showCast}
|
showCast={showCast}
|
||||||
onToggleCast={toggleCast}
|
onToggleCast={toggleCast}
|
||||||
hasCast={false}
|
hasCast={false}
|
||||||
|
|||||||
@@ -221,7 +221,8 @@
|
|||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"countries": "Countries"
|
"countries": "Countries"
|
||||||
}
|
},
|
||||||
|
"prefCrossfade": "Crossfade backdrop"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
|
|||||||
@@ -221,7 +221,8 @@
|
|||||||
"revenue": "Bevétel",
|
"revenue": "Bevétel",
|
||||||
"studios": "Stúdiók",
|
"studios": "Stúdiók",
|
||||||
"countries": "Országok"
|
"countries": "Országok"
|
||||||
}
|
},
|
||||||
|
"prefCrossfade": "Áttűnő háttér"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,18 +24,30 @@ export function useDetailPrefs() {
|
|||||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
const [showRelated, setShowRelated] = usePref<boolean>("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<boolean>("plexInfoArtCrossfade", true);
|
||||||
const savePref = useSavePref();
|
const savePref = useSavePref();
|
||||||
return {
|
return {
|
||||||
artBg,
|
artBg,
|
||||||
showCast,
|
showCast,
|
||||||
showRelated,
|
showRelated,
|
||||||
|
crossfade,
|
||||||
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||||
toggleArtBg: () => setArtBg(!artBg),
|
toggleArtBg: () => setArtBg(!artBg),
|
||||||
toggleCast: () => setShowCast(!showCast),
|
toggleCast: () => setShowCast(!showCast),
|
||||||
toggleRelated: () => setShowRelated(!showRelated),
|
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
|
// 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
|
// 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.
|
// `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
|
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8).
|
||||||
// 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
|
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
|
||||||
// header too), not just the inset <main> box. (A `position:fixed` layer at negative z-index does NOT
|
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
|
||||||
// work here: the body's opaque background is painted over it, so it's invisible; #root's background,
|
// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is
|
||||||
// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed`
|
// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS
|
||||||
// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art.
|
// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the
|
||||||
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
|
// 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(() => {
|
useLayoutEffect(() => {
|
||||||
const el = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (!el) return;
|
if (!root) return;
|
||||||
const s = el.style;
|
const paint = (url: string) => {
|
||||||
const clear = () => {
|
root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`;
|
||||||
s.backgroundImage = "";
|
root.style.backgroundSize = "cover";
|
||||||
s.backgroundSize = "";
|
root.style.backgroundPosition = "center 20%";
|
||||||
s.backgroundPosition = "";
|
root.style.backgroundRepeat = "no-repeat";
|
||||||
s.backgroundRepeat = "";
|
root.style.backgroundAttachment = "fixed";
|
||||||
s.backgroundAttachment = "";
|
// #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) {
|
const clear = () => {
|
||||||
// The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used).
|
root.style.backgroundImage = "";
|
||||||
s.backgroundImage =
|
root.style.backgroundSize = "";
|
||||||
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
root.style.backgroundPosition = "";
|
||||||
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
|
root.style.backgroundRepeat = "";
|
||||||
s.backgroundSize = "cover";
|
root.style.backgroundAttachment = "";
|
||||||
s.backgroundPosition = "center 20%";
|
root.style.isolation = "";
|
||||||
s.backgroundRepeat = "no-repeat";
|
};
|
||||||
s.backgroundAttachment = "fixed";
|
|
||||||
} else {
|
const pool = key ? key.split("|") : [];
|
||||||
|
if (!enabled || pool.length === 0) {
|
||||||
clear();
|
clear();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return clear;
|
paint(pool[0]!);
|
||||||
}, [art, enabled]);
|
|
||||||
|
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<typeof setTimeout> | 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({
|
export function DetailCustomizeMenu({
|
||||||
@@ -106,6 +186,8 @@ export function DetailCustomizeMenu({
|
|||||||
showRelated,
|
showRelated,
|
||||||
onToggleRelated,
|
onToggleRelated,
|
||||||
hasRelated,
|
hasRelated,
|
||||||
|
crossfade,
|
||||||
|
onToggleCrossfade,
|
||||||
overlay,
|
overlay,
|
||||||
extra,
|
extra,
|
||||||
}: {
|
}: {
|
||||||
@@ -117,6 +199,10 @@ export function DetailCustomizeMenu({
|
|||||||
showRelated?: boolean;
|
showRelated?: boolean;
|
||||||
onToggleRelated?: () => void;
|
onToggleRelated?: () => void;
|
||||||
hasRelated?: boolean;
|
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
|
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)
|
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)" }}
|
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||||
>
|
>
|
||||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||||
|
{artBg && onToggleCrossfade && (
|
||||||
|
<PrefToggle
|
||||||
|
label={t("plex.info.prefCrossfade")}
|
||||||
|
on={crossfade ?? true}
|
||||||
|
onClick={onToggleCrossfade}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasCast && (
|
{hasCast && (
|
||||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user