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 { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// 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 ? (
onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:underline"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
{score.toFixed(1)}
) : (
{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. */}
{/* 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) && (