import { useRef, useState, type ReactNode, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, Check, CheckCheck, Play, SlidersHorizontal, type LucideIcon, } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { Chip } from "./ui/Chip"; // 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. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts. // 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). A filtering value // always says so on hover: `title` is a caller's specific hint ("Show titles from 1998"), else the // generic "Filter by …" from the value's own text. export function Filterable({ onClick, title, className, tag = false, onDark = false, children, }: { onClick?: () => void; title?: string; className?: string; /** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge * when it doesn't (E28 S3d: edged = pressable). */ tag?: boolean; onDark?: boolean; children: ReactNode; }) { const { t } = useTranslation(); const hint = title ?? (onClick && (typeof children === "string" || typeof children === "number") ? t("plex.info.filterValue", { name: children }) : undefined); if (tag) return onClick ? ( {children} ) : ( {children} ); if (!onClick) return {children}; return ( {children} ); } 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 (
setOpen((v) => !v)} aria-expanded={open} label={t("plex.info.customize")} > {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 ( {label} ); } // --- 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 BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( ); } export function ActionBtn({ ref, onClick, icon: Icon, label, sub, primary, disabled, }: { onClick: () => void; icon: LucideIcon; label: string; sub?: string; primary?: boolean; disabled?: boolean; ref?: Ref; }) { 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}
{showPlayOverlay && (
)}
{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 ( ); }