import { useState } from "react"; 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 { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; // 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. 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; // 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, 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, toggleArtBg, toggleCast, savePref } = useDetailPrefs(); const prefs = (qc.getQueryData<{ preferences?: Record }>(["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 }>(["me"])?.role) === "admin"; const [editorOpen, setEditorOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false); // Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a // fixed background on
and clears it on unmount / when disabled / in the overlay variant. useArtBackdrop(detail.art, variant !== "overlay" && artBg); const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const setState = async (status: "new" | "watched") => { await api.plexSetState(detail.id, status).catch(() => {}); qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: ["plex-show"] }); 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() }); 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={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} )} {detail.imdb_rating != null && ( {detail.imdb_url && ( )} )}
{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. */} {/* Cast row (toggleable), circular photos. */} {cast.length > 0 && (

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

{cast.map((c, i) => { const inner = ( <>
{c.thumb ? ( ) : (
{c.name.charAt(0)}
)}
{c.name}
{c.role && (
{c.role}
)} ); return onFilter ? ( ) : (
{inner}
); })}
)} {/* 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) => ( ))}
))}
{editorOpen && library && ( c.id)} onClose={() => setEditorOpen(false)} onChanged={() => onStateChange?.()} /> )} {playlistOpen && ( setPlaylistOpen(false)} /> )}
); }