import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { useDismiss } from "../lib/useDismiss"; 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; // Play a sibling title from a collection strip (opens its player). onPlayItem?: (id: string) => 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, onPlayItem, }: 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; plexHiddenStripSources?: string[] }; const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); // 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 [customizing, setCustomizing] = useState(false); const custBtnRef = useRef(null); const custMenuRef = useRef(null); useDismiss(customizing, () => setCustomizing(false), [custMenuRef, custBtnRef]); // Faint art backdrop, HTPC-style: paint the item's art as a FIXED background on the page's scroll // container (
) so it fills the content area and stays put while the info page scrolls over it // (like the body's ambient pools). Scoped to
→ never covers the nav/filter sidebars. A soft // scrim keeps text readable; kept subtle. Page variant only, toggleable, restored on unmount/off. useLayoutEffect(() => { if (variant === "overlay") return; const main = document.querySelector("main"); if (!main) return; const s = main.style; const clear = () => { s.backgroundImage = ""; s.backgroundSize = ""; s.backgroundPosition = ""; s.backgroundRepeat = ""; s.backgroundAttachment = ""; }; if (artBg && detail.art) { s.backgroundImage = `linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` + `color-mix(in srgb, var(--bg) 64%, transparent)), url("${detail.art}")`; s.backgroundSize = "cover"; s.backgroundPosition = "center 20%"; s.backgroundRepeat = "no-repeat"; s.backgroundAttachment = "fixed"; } else { clear(); } return clear; }, [variant, artBg, detail.art]); 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"; // 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}

)}
{customizing && (
{ setArtBg((v) => !v); savePref({ plexInfoArtBg: !artBg }); }} /> { setShowCast((v) => !v); savePref({ plexInfoCast: !showCast }); }} /> {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 ? {fmtDur(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 && ( )}
)}
{/* 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) => ( ))}
))}
); } function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { return ( ); }