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 ? (

{infoTitle}

) : ( {infoTitle} )} {detail.kind === "episode" && (

{episodeLine(detail)}

)}
0} extra={ <> {!overlay && } {presentSources.map((src) => ( toggleSource(src)} /> ))} } /> {overlay && onClose && ( )}
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
{/* 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 } > {vocab(t, "certificate", detail.content_rating)} )} {detail.duration_seconds ? ( {formatRuntime(detail.duration_seconds, lang)} ) : null} {detail.studio && ( onFilter({ studios: [detail.studio!] }) : undefined} > {detail.studio} )}
{/* Multi-source ratings row: IMDb / TMDb / audience, whichever are present. */}
onFilter({ ratingMin: n }) : undefined} onDark={overlay} />
{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) => ( onFilter({ directors: [d] }) : undefined} > {d} ))}

)} {detail.summary && (

{detail.summary}

)} {/* Page actions: Play/Resume + watch controls */} {!overlay && (
{onPlay && ( )} {detail.status === "watched" ? ( ) : ( )} {inProgress && ( )} {detail.kind === "movie" && ( )} {isAdmin && detail.kind === "movie" && library && ( )}
)} {!overlay && aside.shown.keywords && ( )}
{asideShown && ( )}
{/* 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.
onPlayItem(m.id)} onRestart={() => onPlayItem(m.id, true)} onToggleWatched={toggleColWatched} onAdd={colActions.add} menuProps={colMenu.triggerProps} menuButtonProps={colMenu.buttonProps} menuOpen={colMenu.open && colMenu.target?.id === m.id} />
))}
)); const castRow = cast.length > 0 && (

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

); if (overlay) return ( <> {strips} {castRow} ); if (strips.length === 0 && !castRow) return null; return (
{strips} {castRow}
); })()}
{editorOpen && library && ( c.id)} onClose={() => setEditorOpen(false)} onChanged={() => onStateChange?.()} /> )} {playlistOpen && ( setPlaylistOpen(false)} /> )}
); }