Files
siftlode/frontend/src/lib/plexDetailUi.tsx
T
peter b83248c510 fix(e5): Plex detail/UI polish from review (backdrop reset, layout, combobox, labels)
User-reported issues after the E5 build:

- useCrossfadeBackdrop: the static branch (one image / crossfade off /
  reduced motion) painted #root but returned NO cleanup, so the backdrop
  persisted after leaving the page (e.g. switching modules). Every painted
  branch now returns clear() so unmount always resets the default backdrop.
- scheduler: give the tmdb_enrich job a proper label + description (was the
  raw "tmdb_enrich" id) in EN/HU.
- CastStrip: cap crew names to ONE line (truncate) so the row of circular
  photos sits on one baseline instead of going wavy on 2-line names.
- PlexInfo: lay Details / Keywords / Crew out as side-by-side boxes
  (items-start grid) instead of full-width stacked panels with dead space;
  narrower Crew grid for the boxed width; each panel independently hideable
  from the customize menu (new Details/Keywords/Crew toggles). Tighter
  vertical spacing.
- MultiSelectChips: reshape the trigger from a "+Add" button into an
  Overseerr-style combi input — removable chips inside a typeable field that
  filters the checklist, opening on focus (backspace removes the last chip).
- Discover / person rails: reduce the vertical gap between rails.
2026-08-21 03:16:40 +02:00

417 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Check,
CheckCheck,
Play,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useDismiss } from "./useDismiss";
import { usePref, useSavePref } from "./usePref";
import type { PlexCard } 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() {
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
// usePref seeds from the cache (no localStorage mirror).
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.
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>
);
}
// 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 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";
};
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 clear; // and clear again on unmount / dep-change (idempotent)
}
paint(pool[0]!);
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || reduce || pool.length < 2) return clear;
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({
artBg,
onToggleArtBg,
showCast,
onToggleCast,
hasCast,
showRelated,
onToggleRelated,
hasRelated,
crossfade,
onToggleCrossfade,
overlay,
extra,
}: {
artBg: boolean;
onToggleArtBg: () => void;
showCast: boolean;
onToggleCast: () => void;
hasCast: boolean;
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)
}) {
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} />
{artBg && onToggleCrossfade && (
<PrefToggle
label={t("plex.info.prefCrossfade")}
on={crossfade ?? true}
onClick={onToggleCrossfade}
/>
)}
{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>
);
}
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
export function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
export function ActionBtn({
onClick,
icon: Icon,
label,
sub,
primary,
disabled,
}: {
onClick: () => void;
icon: LucideIcon;
label: string;
sub?: string;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
* `children`. Poster is slightly larger on the show page (default) than the season page. */
export function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = "w-32 sm:w-44",
customizeMenu,
children,
}: {
thumb: string;
posterTitle: string;
posterDisabled: boolean;
showPlayOverlay: boolean;
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
children: ReactNode;
}) {
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<button
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<img
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{showPlayOverlay && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
* the show and season heroes; the labels differ, so the caller passes both. */
export function WatchedToggle({
watched,
busy,
onToggle,
watchedLabel,
unwatchedLabel,
}: {
watched: boolean;
busy?: boolean;
onToggle: () => void;
watchedLabel: string;
unwatchedLabel: string;
}) {
return (
<ActionBtn
onClick={onToggle}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? watchedLabel : unwatchedLabel}
/>
);
}