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); const savePref = useSavePref(); return { artBg, showCast, showRelated, savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) toggleArtBg: () => setArtBg(!artBg), toggleCast: () => setShowCast(!showCast), toggleRelated: () => setShowRelated(!showRelated), }; } // 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 ( ); } // 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
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(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 && (
{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 ( ); }