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 (
{has(imdbRating) && ( IMDb {imdbUrl && ( )} )} {has(tmdbRating) && ( TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} )} {has(audience) && imdbRating !== audience && ( {t("plex.info.audience")} )}
); } // A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries, // pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something. function FactsPanel({ detail, onFilter, }: { detail: PlexItemDetail; onFilter?: (patch: Partial) => void; }) { const { t } = useTranslation(); const tmdb = detail.tmdb; const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; const rows: { key: string; label: string; node: ReactNode }[] = []; if (detail.year != null) rows.push({ key: "year", label: t("plex.info.facts.release"), node: detail.year }); if (detail.duration_seconds) rows.push({ key: "runtime", label: t("plex.info.facts.runtime"), node: formatRuntime(detail.duration_seconds), }); if (detail.content_rating) rows.push({ key: "cert", label: t("plex.info.facts.cert"), node: detail.content_rating }); if (tmdb?.budget) rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) }); if (tmdb?.revenue) rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) }); if (studios.length) rows.push({ key: "studios", label: t("plex.info.facts.studios"), node: ( {studios.map((s, i) => ( onFilter({ studios: [s] }) : undefined} className="hover:text-accent" > {s} {i < studios.length - 1 ? "," : ""} ))} ), }); if (tmdb?.production_countries?.length) rows.push({ key: "countries", label: t("plex.info.facts.countries"), node: tmdb.production_countries.join(", "), }); if (rows.length === 0) return null; return (

{t("plex.info.facts.title")}

{rows.map((r) => (
{r.label}
{r.node}
))}
); } // TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter). function KeywordsPanel({ keywords, onFilter, }: { keywords: string[]; onFilter?: (patch: Partial) => void; }) { const { t } = useTranslation(); if (keywords.length === 0) return null; return (

{t("plex.info.keywords")}

{keywords.map((k) => ( onFilter({ keywords: [k] }) : undefined} > {k} ))}
); } // 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). */}
{/* Header: close (overlay) / customize */}

{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}

{detail.kind === "episode" && (

S{detail.season_number}·E{detail.episode_number} — {detail.title}

)}
0} extra={ <> {hasFacts && ( setShowFacts(!showFacts)} /> )} {hasKeywords && ( setShowKeywords(!showKeywords)} /> )} {hasCrew && ( setShowCrew(!showCrew)} /> )} {presentSources.map((src) => ( toggleSource(src)} /> ))} } /> {overlay && onClose && ( )}
{/* Poster */}
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
{detail.year != null && ( onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined } > {detail.year} )} {detail.content_rating && ( onFilter({ contentRatings: [detail.content_rating!] }) : undefined } > {detail.content_rating} )} {detail.duration_seconds ? ( {formatRuntime(detail.duration_seconds)} ) : null} {detail.studio && ( onFilter({ studios: [detail.studio!] }) : undefined} > {detail.studio} )}
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
onFilter({ ratingMin: n }) : undefined} />
{detail.tagline && (

{detail.tagline}

)} {detail.genres.length > 0 && (
{detail.genres.map((g) => ( onFilter({ genres: [g] }) : undefined} > {g} ))}
)} {detail.directors.length > 0 && (

{t("plex.info.director")}:{" "} {detail.directors.map((d, i) => ( onFilter({ directors: [d] }) : undefined} > {d} {i < detail.directors.length - 1 ? ", " : ""} ))}

)} {detail.summary && (

{detail.summary}

)} {/* Page actions: Play/Resume + watch controls */} {!overlay && (
{onPlay && ( )} {detail.status === "watched" ? ( ) : ( )} {inProgress && ( )} {detail.kind === "movie" && ( )} {isAdmin && detail.kind === "movie" && library && ( )}
)}
{/* 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) && (
{showFacts && } {showKeywords && ( )} {showCrew && }
)} {/* Cast row (toggleable), circular photos. */} {cast.length > 0 && (

{t("plex.info.cast")}

{cast.map((c, i) => { const clickable = !!(onOpenPerson || onFilter); const inner = ( <>
{c.thumb ? ( ) : (
{c.name.charAt(0)}
)}
{c.name}
{c.role && (
{c.role}
)} ); return clickable ? ( ) : (
{inner}
); })}
)} {/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */} {/* Collection strips — the other titles in this movie's collection(s), directly playable. Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */} {detail.collections ?.filter((col) => !hiddenSources.includes(col.source)) .map((col) => (

{col.title}

{onFilter && ( )}
{col.items.map((m, i) => ( onPlayItem?.(m.id)} disabled={!onPlayItem} className="group/col w-28 shrink-0 text-left" >
{m.status === "watched" && ( ✓ )} {onPlayItem && (
)} {(m.position_seconds ?? 0) > 0 && m.status !== "watched" && m.duration_seconds ? (
) : null}
{m.title}
{m.year ?
{m.year}
: null} ))}
))}
{editorOpen && library && ( c.id)} onClose={() => setEditorOpen(false)} onChanged={() => onStateChange?.()} /> )} {playlistOpen && ( setPlaylistOpen(false)} /> )}
); }