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("plexInfoArtBg", true); const [showCast, setShowCast] = usePref("plexInfoCast", true); const [showRelated, setShowRelated] = usePref("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("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 {children}; return ( ); } // 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 | 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(null); const menuRef = useRef(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 — 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 (
{open && (
{artBg && onToggleCrossfade && ( )} {hasCast && ( )} {hasRelated && onToggleRelated && ( )} {extra}
)}
); } /** 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 ( ); } // --- 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 ( ); } export function ActionBtn({ onClick, icon: Icon, label, sub, primary, disabled, }: { onClick: () => void; icon: LucideIcon; label: string; sub?: string; primary?: boolean; disabled?: boolean; }) { return ( ); } /** 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 (
{customizeMenu}
{children}
); } /** 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 ( ); }