import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { navItemAttrs } from "../lib/listNav";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
// on the page variant only; the overlay stays lean.
// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts);
// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays
// USD — TMDB budget/revenue figures are USD regardless of the viewer's language.
const compact = (n: number) =>
new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
const money = (n: number) =>
new Intl.NumberFormat(i18n.language, {
notation: "compact",
style: "currency",
currency: "USD",
// The minimum is load-bearing, not a default written out: given only a maximum, a compact
// format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M"
// on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one
// was green), and browsers split along the same line. Pin it rather than inherit the engine's.
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(n);
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
// declared during render is a NEW component type on every render — React unmounts and remounts the
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
// on the button while the info page re-renders around it.
function Score({
score,
filterable,
onFilterRating,
}: {
score: number;
filterable: boolean;
onFilterRating?: (score: number) => void;
}) {
const { t } = useTranslation();
return filterable && onFilterRating ? (
) : (
{score.toFixed(1)}
);
}
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
function RatingsRow({
imdbRating,
imdbUrl,
audience,
tmdbRating,
tmdbVotes,
onFilterRating,
}: {
imdbRating?: number | null;
imdbUrl?: string | null;
audience?: number | null;
tmdbRating?: number | null;
tmdbVotes?: number | null;
onFilterRating?: (score: number) => void;
}) {
const { t } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls =
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
);
}
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
// their person page. Complements the hero's Director line (which comes from Plex directly).
function CrewPanel({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
{t("plex.info.crew")}
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
{crew.map((c, i) => (
{onOpenPerson ? (
) : (
{c.name}
)}
{c.job}
))}
);
}
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
const { t } = useTranslation();
if (!detail.playable) return null;
const cls = playableClass(detail.playable, detail.transcode_hw);
const sub = [
detail.codec_video ? detail.codec_video.toUpperCase() : null,
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
]
.filter(Boolean)
.join(" · ");
return (
{t(`plex.filter.playableOpt.${cls}`)}
{sub && {sub}}
);
}
type Props = {
detail: PlexItemDetail;
variant: "page" | "overlay";
onPlay?: () => void; // page: start/resume playback
onClose?: () => void; // overlay: dismiss
onStateChange?: () => void; // after a watch-state change, let the opener refresh
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
// the corresponding Plex filter (then the opener navigates to the filtered grid).
onFilter?: (patch: Partial) => void;
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
onOpenPerson?: (name: string) => void;
// Play a sibling title from a collection strip (opens its player).
onPlayItem?: (id: string) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
library?: string;
};
export default function PlexInfo({
detail,
variant,
onPlay,
onClose,
onStateChange,
onFilter,
onOpenPerson,
onPlayItem,
library,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref("plexInfoCrew", true);
const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
const [hiddenSources, setHiddenSources] = useState(
Array.isArray(prefs.plexHiddenStripSources) ? prefs.plexHiddenStripSources : [],
);
const toggleSource = (src: string) => {
setHiddenSources((cur) => {
const next = cur.includes(src) ? cur.filter((s) => s !== src) : [...cur, src];
savePref({ plexHiddenStripSources: next });
return next;
});
};
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
// variant / when disabled / on unmount.
const backdropPool = [
detail.art,
...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []),
];
useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade);
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => {
try {
await api.plexSetState(detail.id, status);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
onStateChange?.();
};
const cast = showCast ? detail.cast : [];
const overlay = variant === "overlay";
const mutedCls = overlay ? "text-white/70" : "text-muted";
// Distinct strip-source buckets present on THIS item, in strip order — one toggle each.
const presentSources = (detail.collections ?? []).reduce((acc, c) => {
if (!acc.includes(c.source)) acc.push(c.source);
return acc;
}, []);
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
return (
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (