Follow-ups on the 7-item batch: - The slim nav slot was still w-20 while its card is w-14, leaving a ~24px dead gap the filter tab/bar couldn't dock against. Slot is now w-14 (exactly the card width), so the filter's flat-left tab sits flush on the rail's right edge (measured gap 0), both collapsed and expanded. - The Plex art backdrop was invisible: a position:fixed layer at z-index -1 is painted BEHIND the body's opaque background here, so nothing showed. Paint the art on the app root (#root) instead — it's exactly the viewport and a real ancestor of the transparent nav/header/content, so the art fills the whole page edge-to-edge and the glass chrome floats over it (verified in-browser).
210 lines
7.5 KiB
TypeScript
210 lines
7.5 KiB
TypeScript
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||
import { qk } from "./queryKeys";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { SlidersHorizontal } from "lucide-react";
|
||
import { useDismiss } from "./useDismiss";
|
||
import { api } from "./api";
|
||
|
||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
|
||
// customize menu that toggles them.
|
||
|
||
export function useDetailPrefs() {
|
||
const qc = useQueryClient();
|
||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||
{}) as {
|
||
plexInfoArtBg?: boolean;
|
||
plexInfoCast?: boolean;
|
||
plexInfoRelated?: boolean;
|
||
};
|
||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||
const save = (patch: Record<string, unknown>) => {
|
||
api.savePrefs(patch).catch(() => {});
|
||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||
);
|
||
};
|
||
return {
|
||
artBg,
|
||
showCast,
|
||
showRelated,
|
||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||
toggleArtBg: () => {
|
||
const next = !artBg;
|
||
setArtBg(next);
|
||
save({ plexInfoArtBg: next });
|
||
},
|
||
toggleCast: () => {
|
||
const next = !showCast;
|
||
setShowCast(next);
|
||
save({ plexInfoCast: next });
|
||
},
|
||
toggleRelated: () => {
|
||
const next = !showRelated;
|
||
setShowRelated(next);
|
||
save({ plexInfoRelated: next });
|
||
},
|
||
};
|
||
}
|
||
|
||
// 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.
|
||
export function Filterable({
|
||
onClick,
|
||
title,
|
||
className,
|
||
children,
|
||
}: {
|
||
onClick?: () => void;
|
||
title?: string;
|
||
className?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
if (!onClick) return <span className={className}>{children}</span>;
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
title={title}
|
||
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// 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) {
|
||
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 = "";
|
||
};
|
||
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 {
|
||
clear();
|
||
}
|
||
return clear;
|
||
}, [art, enabled]);
|
||
}
|
||
|
||
export function DetailCustomizeMenu({
|
||
artBg,
|
||
onToggleArtBg,
|
||
showCast,
|
||
onToggleCast,
|
||
hasCast,
|
||
showRelated,
|
||
onToggleRelated,
|
||
hasRelated,
|
||
overlay,
|
||
extra,
|
||
}: {
|
||
artBg: boolean;
|
||
onToggleArtBg: () => void;
|
||
showCast: boolean;
|
||
onToggleCast: () => void;
|
||
hasCast: boolean;
|
||
showRelated?: boolean;
|
||
onToggleRelated?: () => void;
|
||
hasRelated?: boolean;
|
||
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)
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [open, setOpen] = useState(false);
|
||
const btnRef = useRef<HTMLButtonElement>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
|
||
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
|
||
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
|
||
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
|
||
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
|
||
// any of them needs the platform to portal INTO a caller-named (scaled) container.
|
||
return (
|
||
<div className="relative shrink-0">
|
||
<button
|
||
ref={btnRef}
|
||
onClick={() => setOpen((v) => !v)}
|
||
title={t("plex.info.customize")}
|
||
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||
>
|
||
<SlidersHorizontal className="w-4 h-4" />
|
||
</button>
|
||
{open && (
|
||
<div
|
||
ref={menuRef}
|
||
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||
>
|
||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||
{hasCast && (
|
||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||
)}
|
||
{hasRelated && onToggleRelated && (
|
||
<PrefToggle
|
||
label={t("plex.info.prefRelated")}
|
||
on={showRelated ?? true}
|
||
onClick={onToggleRelated}
|
||
/>
|
||
)}
|
||
{extra}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
|
||
* so a plain button, not a menuitemradio. */
|
||
export function PrefToggle({
|
||
label,
|
||
on,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
on: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||
>
|
||
<span>{label}</span>
|
||
<span
|
||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
||
/>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|