import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, Play, RotateCcw, SlidersHorizontal, Star, X, } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; // 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; }; // A metadata value that filters the grid when clicked (if onFilter is wired), else plain text. function Filterable({ onClick, title, className, children, }: { onClick?: () => void; title?: string; className?: string; children: ReactNode; }) { if (!onClick) return {children}; return ( ); } function fmtDur(s?: number | null): string { if (!s) return ""; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; } export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) { const { t } = useTranslation(); const qc = useQueryClient(); // Personal display prefs (default ON). Stored per-user; the backend merges any pref key. const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean }; const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); const [customizing, setCustomizing] = useState(false); const savePref = (patch: Record) => { api.savePrefs(patch).catch(() => {}); qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, ); }; 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-browse"] }); qc.invalidateQueries({ queryKey: ["plex-show"] }); onStateChange?.(); }; const cast = showCast ? detail.cast : []; const overlay = variant === "overlay"; const mutedCls = overlay ? "text-white/70" : "text-muted"; return (
{/* Faint art backdrop (page only, toggleable). */} {!overlay && artBg && detail.art && (
)}
{/* 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}

)}
{customizing && (
{ setArtBg((v) => !v); savePref({ plexInfoArtBg: !artBg }); }} /> { setShowCast((v) => !v); savePref({ plexInfoCast: !showCast }); }} />
)}
{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 ? {fmtDur(detail.duration_seconds)} : null} {detail.studio && ( onFilter({ studio: 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({ director: d }) : undefined} > {d} {i < detail.directors.length - 1 ? ", " : ""} ))}

)} {detail.summary && (

{detail.summary}

)} {/* Page actions: Play/Resume + watch controls */} {!overlay && (
{onPlay && ( )} {detail.status === "watched" ? ( ) : ( )} {inProgress && ( )}
)}
{/* 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}
); })}
)}
); } function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { return ( ); }