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 (
);
}
/** 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 (
} onClick={onBack} className="mb-4">
{label}
);
}
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 (
}
>
{label}
{/* The resume point rides on the same line: the md rung is one line tall (B1). */}
{sub && · {sub}}
);
}
/** 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 (
);
}