import { useState } 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 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 }; 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 }: 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 meta = [ detail.year, detail.content_rating, fmtDur(detail.duration_seconds), detail.studio, ].filter(Boolean); const cast = showCast ? detail.cast : []; const overlay = variant === "overlay"; 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 */}
{meta.map((m, i) => ( {m} ))} {detail.imdb_rating != null && ( {detail.imdb_rating.toFixed(1)} {detail.imdb_url && } )}
{detail.tagline && (

{detail.tagline}

)} {detail.genres.length > 0 && (
{detail.genres.map((g) => ( {g} ))}
)} {detail.directors.length > 0 && (

{t("plex.info.director")}: {detail.directors.join(", ")}

)} {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) => (
{c.thumb ? ( ) : (
{c.name.charAt(0)}
)}
{c.name}
{c.role && (
{c.role}
)}
))}
)}
); } function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { return ( ); }