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.
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|