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}
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"revenue": "Revenue",
|
||||
"studios": "Studios",
|
||||
"countries": "Countries"
|
||||
}
|
||||
},
|
||||
"prefCrossfade": "Crossfade backdrop"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Plays directly in the browser",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"revenue": "Bevétel",
|
||||
"studios": "Stúdiók",
|
||||
"countries": "Országok"
|
||||
}
|
||||
},
|
||||
"prefCrossfade": "Áttűnő háttér"
|
||||
},
|
||||
"playable": {
|
||||
"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 [showCast, setShowCast] = usePref<boolean>("plexInfoCast", 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();
|
||||
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 <main> 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<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({
|
||||
@@ -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)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{artBg && onToggleCrossfade && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCrossfade")}
|
||||
on={crossfade ?? true}
|
||||
onClick={onToggleCrossfade}
|
||||
/>
|
||||
)}
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user