import { useState } 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 PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatCompact, formatDecimal, formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
DETAIL_POSTER,
episodeLine,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
useDetailPrefs,
} from "../lib/plexDetail";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
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 { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import { CastRow, PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { toggleWatchState } from "../lib/plexWatch";
import { vocab } from "../lib/vocab";
// 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) render
// on the page variant only; the overlay stays lean.
// 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, i18n } = useTranslation();
const shown = formatDecimal(score, i18n.language);
return filterable && onFilterRating ? (
onFilterRating(Math.floor(score))}
className="inline-flex min-h-6 items-center gap-1 hover:underline touch-target"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
{shown}
) : (
{shown}
);
}
// Multi-source ratings cluster: 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,
onDark,
}: {
imdbRating?: number | null;
imdbUrl?: string | null;
audience?: number | null;
tmdbRating?: number | null;
tmdbVotes?: number | null;
onFilterRating?: (score: number) => void;
/** Over the always-dark player: the page's `--rating` is tuned for the theme's page, so a light
* theme's dark gold would sit on the player's black (2.6:1, measured). */
onDark?: boolean;
}) {
const { t, i18n } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
onDark ? "bg-rating-media/15 text-rating-media" : "bg-rating/15 text-rating"
}`;
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
{has(imdbRating) && (
IMDb
)}
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
{has(imdbRating) && imdbUrl && (
)}
{has(tmdbRating) && (
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
)}
{has(audience) && imdbRating !== audience && (
{t("plex.info.audience")}
)}
);
}
// 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: the page opens its player, the overlay plays it
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
onPlayItem: (id: string, fromStart?: boolean) => 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, i18n } = useTranslation();
const lang = i18n.language;
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 TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
const aside = useDetailAside(detail);
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, and its collection siblings' art. 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?.();
};
// A collection sibling is a Plex card like any other: the related strip's menu, its
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
// is; the resync corrects it to the truth either way.
const colMenu = useContextMenu();
const toggleColWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueriesData({ queryKey: qk.plexItem(detail.id) }, (old) =>
old
? {
...old,
collections: old.collections?.map((col) => ({
...col,
items: col.items.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
})),
}
: old,
),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexItem(card.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
const colActions = usePlexCardActions(
{
onOpen: (c) => onPlayItem(c.id),
onRestart: (c) => onPlayItem(c.id, true),
onToggleWatched: toggleColWatched,
},
colMenu.open,
);
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() });
const asideShown = !overlay && aside.column;
const infoTitle =
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
return (
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* Header: close (overlay) / customize */}
{/* The page's own h1; over the running player it is the info dialog's title. */}
{overlay ? (
{/* End hero panel. */}
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
sat beside them moved up into the hero's second column. The overlay (over
the running player) is the same stack, lean. */}
{(() => {
const collections = (detail.collections ?? []).filter(
(col) => !hiddenSources.includes(col.source),
);
const strips = collections.map((col) => (
{col.title}
{onFilter && (
)}
{col.items.map((m, i) => (
// The one media card, as on every other strip. A grid cell, so the
// card fills the slot the carousel stretches to its tallest card.