Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
820 lines
33 KiB
TypeScript
820 lines
33 KiB
TypeScript
import { useState, type ReactNode } 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 PlexFilters, type PlexItemDetail } from "../lib/api";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
|
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
|
|
import { usePref } from "../lib/usePref";
|
|
import i18n from "../i18n";
|
|
import { Carousel } from "./ui/Carousel";
|
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
|
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
|
import { navItemAttrs } from "../lib/listNav";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
|
|
// 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 — E5 S5) render
|
|
// on the page variant only; the overlay stays lean.
|
|
|
|
// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts);
|
|
// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays
|
|
// USD — TMDB budget/revenue figures are USD regardless of the viewer's language.
|
|
const compact = (n: number) =>
|
|
new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
|
const money = (n: number) =>
|
|
new Intl.NumberFormat(i18n.language, {
|
|
notation: "compact",
|
|
style: "currency",
|
|
currency: "USD",
|
|
// The minimum is load-bearing, not a default written out: given only a maximum, a compact
|
|
// format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M"
|
|
// on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one
|
|
// was green), and browsers split along the same line. Pin it rather than inherit the engine's.
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 1,
|
|
}).format(n);
|
|
|
|
// 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 } = useTranslation();
|
|
return filterable && onFilterRating ? (
|
|
<Pressable
|
|
onClick={() => onFilterRating(Math.floor(score))}
|
|
className="inline-flex items-center gap-1 hover:underline"
|
|
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
|
>
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{score.toFixed(1)}
|
|
</Pressable>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{score.toFixed(1)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Multi-source ratings cluster (E5 S5): 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,
|
|
}: {
|
|
imdbRating?: number | null;
|
|
imdbUrl?: string | null;
|
|
audience?: number | null;
|
|
tmdbRating?: number | null;
|
|
tmdbVotes?: number | null;
|
|
onFilterRating?: (score: number) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const primary = imdbRating ?? audience ?? null; // the filterable score
|
|
const chipCls =
|
|
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
|
|
const has = (x?: number | null): x is number => x != null;
|
|
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
{has(imdbRating) && (
|
|
<span className={chipCls}>
|
|
<Score
|
|
score={imdbRating}
|
|
filterable={imdbRating === primary}
|
|
onFilterRating={onFilterRating}
|
|
/>
|
|
<span className="text-2xs font-normal opacity-70">IMDb</span>
|
|
{imdbUrl && (
|
|
<a
|
|
href={imdbUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={t("plex.info.openImdb")}
|
|
>
|
|
<ExternalLink className="h-3 w-3 opacity-70 hover:opacity-100" />
|
|
</a>
|
|
)}
|
|
</span>
|
|
)}
|
|
{has(tmdbRating) && (
|
|
<span className={chipCls}>
|
|
<Score score={tmdbRating} filterable={false} />
|
|
<span className="text-2xs font-normal opacity-70">
|
|
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{has(audience) && imdbRating !== audience && (
|
|
<span className={chipCls}>
|
|
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
|
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
|
|
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
|
|
function FactsPanel({
|
|
detail,
|
|
onFilter,
|
|
}: {
|
|
detail: PlexItemDetail;
|
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tmdb = detail.tmdb;
|
|
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
|
|
const rows: { key: string; label: string; node: ReactNode }[] = [];
|
|
if (detail.year != null)
|
|
rows.push({ key: "year", label: t("plex.info.facts.release"), node: detail.year });
|
|
if (detail.duration_seconds)
|
|
rows.push({
|
|
key: "runtime",
|
|
label: t("plex.info.facts.runtime"),
|
|
node: formatRuntime(detail.duration_seconds),
|
|
});
|
|
if (detail.content_rating)
|
|
rows.push({ key: "cert", label: t("plex.info.facts.cert"), node: detail.content_rating });
|
|
if (tmdb?.budget)
|
|
rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) });
|
|
if (tmdb?.revenue)
|
|
rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) });
|
|
if (studios.length)
|
|
rows.push({
|
|
key: "studios",
|
|
label: t("plex.info.facts.studios"),
|
|
node: (
|
|
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
|
|
{studios.map((s, i) => (
|
|
<span key={s}>
|
|
<Filterable
|
|
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
|
className="hover:text-accent"
|
|
>
|
|
{s}
|
|
</Filterable>
|
|
{i < studios.length - 1 ? "," : ""}
|
|
</span>
|
|
))}
|
|
</span>
|
|
),
|
|
});
|
|
if (tmdb?.production_countries?.length)
|
|
rows.push({
|
|
key: "countries",
|
|
label: t("plex.info.facts.countries"),
|
|
node: tmdb.production_countries.join(", "),
|
|
});
|
|
if (rows.length === 0) return null;
|
|
return (
|
|
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
|
{rows.map((r) => (
|
|
<div key={r.key} className="contents">
|
|
<dt className="text-muted">{r.label}</dt>
|
|
<dd className="min-w-0 font-medium">{r.node}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
|
|
function KeywordsPanel({
|
|
keywords,
|
|
onFilter,
|
|
}: {
|
|
keywords: string[];
|
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (keywords.length === 0) return null;
|
|
return (
|
|
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{keywords.map((k) => (
|
|
<Filterable
|
|
key={k}
|
|
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
|
|
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
|
|
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
|
>
|
|
{k}
|
|
</Filterable>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
|
|
// their person page. Complements the hero's Director line (which comes from Plex directly).
|
|
function CrewPanel({
|
|
crew,
|
|
onOpenPerson,
|
|
}: {
|
|
crew: { name: string; job: string }[];
|
|
onOpenPerson?: (name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (crew.length === 0) return null;
|
|
return (
|
|
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
|
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
|
|
so a fixed small column count keeps names from over-spreading. */}
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
{crew.map((c, i) => (
|
|
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
|
{onOpenPerson ? (
|
|
<Pressable
|
|
onClick={() => onOpenPerson(c.name)}
|
|
title={t("plex.info.viewPerson", { name: c.name })}
|
|
className="max-w-full truncate text-sm font-medium hover:text-accent"
|
|
>
|
|
{c.name}
|
|
</Pressable>
|
|
) : (
|
|
<div className="truncate text-sm font-medium">{c.name}</div>
|
|
)}
|
|
<div className="truncate text-2xs text-muted">{c.job}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
|
|
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
|
|
}`}
|
|
title={t(`plex.playable.${cls}`)}
|
|
>
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
|
|
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
|
{sub && <span className="opacity-70">{sub}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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<PlexFilters>) => 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 (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,
|
|
onOpenPerson,
|
|
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, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
|
useDetailPrefs();
|
|
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
|
|
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
|
|
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
|
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
|
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(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<string[]>(
|
|
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 (E5 S2), and its collection siblings' art (E5 S8). 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?.();
|
|
};
|
|
|
|
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<string[]>((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() });
|
|
|
|
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
|
|
// (no toggle for a panel that would be empty anyway).
|
|
const tmdb = detail.tmdb;
|
|
const hasFacts =
|
|
detail.year != null ||
|
|
!!detail.duration_seconds ||
|
|
!!detail.content_rating ||
|
|
!!tmdb?.budget ||
|
|
!!tmdb?.revenue ||
|
|
!!tmdb?.studios?.length ||
|
|
!!detail.studio ||
|
|
!!tmdb?.production_countries?.length;
|
|
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
|
|
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
|
|
|
|
return (
|
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
|
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
|
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
|
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
|
|
{/* Header: close (overlay) / customize */}
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
|
|
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
|
|
</h1>
|
|
{detail.kind === "episode" && (
|
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
|
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DetailCustomizeMenu
|
|
overlay={overlay}
|
|
artBg={artBg}
|
|
onToggleArtBg={toggleArtBg}
|
|
crossfade={crossfade}
|
|
onToggleCrossfade={toggleCrossfade}
|
|
showCast={showCast}
|
|
onToggleCast={toggleCast}
|
|
hasCast={detail.cast.length > 0}
|
|
extra={
|
|
<>
|
|
{hasFacts && (
|
|
<PrefToggle
|
|
label={t("plex.info.prefFacts")}
|
|
on={showFacts}
|
|
onClick={() => setShowFacts(!showFacts)}
|
|
/>
|
|
)}
|
|
{hasKeywords && (
|
|
<PrefToggle
|
|
label={t("plex.info.prefKeywords")}
|
|
on={showKeywords}
|
|
onClick={() => setShowKeywords(!showKeywords)}
|
|
/>
|
|
)}
|
|
{hasCrew && (
|
|
<PrefToggle
|
|
label={t("plex.info.prefCrew")}
|
|
on={showCrew}
|
|
onClick={() => setShowCrew(!showCrew)}
|
|
/>
|
|
)}
|
|
{presentSources.map((src) => (
|
|
<PrefToggle
|
|
key={src}
|
|
label={sourceLabel(src)}
|
|
on={!hiddenSources.includes(src)}
|
|
onClick={() => toggleSource(src)}
|
|
/>
|
|
))}
|
|
</>
|
|
}
|
|
/>
|
|
{overlay && onClose && (
|
|
<IconButton
|
|
variant="overlay"
|
|
size="md"
|
|
onClick={onClose}
|
|
label={t("plex.player.close")}
|
|
>
|
|
<X />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-4 sm:gap-6">
|
|
{/* Poster */}
|
|
<img
|
|
src={detail.thumb}
|
|
alt=""
|
|
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
|
/>
|
|
|
|
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
|
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
{detail.year != null && (
|
|
<Filterable
|
|
className={mutedCls}
|
|
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
|
onClick={
|
|
onFilter
|
|
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
|
|
: undefined
|
|
}
|
|
>
|
|
{detail.year}
|
|
</Filterable>
|
|
)}
|
|
{detail.content_rating && (
|
|
<Filterable
|
|
className={mutedCls}
|
|
onClick={
|
|
onFilter
|
|
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
|
: undefined
|
|
}
|
|
>
|
|
{detail.content_rating}
|
|
</Filterable>
|
|
)}
|
|
{detail.duration_seconds ? (
|
|
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span>
|
|
) : null}
|
|
{detail.studio && (
|
|
<Filterable
|
|
className={mutedCls}
|
|
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
|
>
|
|
{detail.studio}
|
|
</Filterable>
|
|
)}
|
|
<PlaybackChip detail={detail} overlay={overlay} />
|
|
</div>
|
|
|
|
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
|
<div className="mt-2">
|
|
<RatingsRow
|
|
imdbRating={detail.imdb_rating}
|
|
imdbUrl={detail.imdb_url}
|
|
audience={detail.rating}
|
|
tmdbRating={detail.tmdb?.rating}
|
|
tmdbVotes={detail.tmdb?.vote_count}
|
|
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{detail.tagline && (
|
|
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
|
{detail.tagline}
|
|
</p>
|
|
)}
|
|
|
|
{detail.genres.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{detail.genres.map((g) => (
|
|
<Filterable
|
|
key={g}
|
|
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
|
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
|
>
|
|
{g}
|
|
</Filterable>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{detail.directors.length > 0 && (
|
|
<p className={`mt-2 text-sm ${mutedCls}`}>
|
|
{t("plex.info.director")}:{" "}
|
|
{detail.directors.map((d, i) => (
|
|
<span key={d}>
|
|
<Filterable
|
|
className="font-medium"
|
|
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
|
>
|
|
{d}
|
|
</Filterable>
|
|
{i < detail.directors.length - 1 ? ", " : ""}
|
|
</span>
|
|
))}
|
|
</p>
|
|
)}
|
|
|
|
{detail.summary && (
|
|
<p
|
|
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
|
>
|
|
{detail.summary}
|
|
</p>
|
|
)}
|
|
|
|
{/* Page actions: Play/Resume + watch controls */}
|
|
{!overlay && (
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
{onPlay && (
|
|
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
|
|
{inProgress ? t("plex.resume") : t("plex.play")}
|
|
</Button>
|
|
)}
|
|
{detail.status === "watched" ? (
|
|
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
|
{t("plex.info.markUnwatched")}
|
|
</Button>
|
|
) : (
|
|
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
|
{t("plex.info.markWatched")}
|
|
</Button>
|
|
)}
|
|
{inProgress && (
|
|
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
|
{t("plex.info.clearResume")}
|
|
</Button>
|
|
)}
|
|
{detail.kind === "movie" && (
|
|
<Button
|
|
variant="glass"
|
|
onClick={() => setPlaylistOpen(true)}
|
|
icon={<ListPlus />}
|
|
>
|
|
{t("plex.playlistAdd.manage")}
|
|
</Button>
|
|
)}
|
|
{isAdmin && detail.kind === "movie" && library && (
|
|
<Button
|
|
variant="glass"
|
|
onClick={() => setEditorOpen(true)}
|
|
icon={<FolderPlus />}
|
|
>
|
|
{t("plex.collEditor.manage")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* End hero panel. */}
|
|
|
|
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
|
|
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
|
|
width with dead space; each is independently hideable from the customize menu. */}
|
|
{!overlay && (showFacts || showKeywords || showCrew) && (
|
|
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
|
|
{showKeywords && (
|
|
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
|
|
)}
|
|
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
|
</div>
|
|
)}
|
|
|
|
{/* Cast row (toggleable), circular photos. */}
|
|
{cast.length > 0 && (
|
|
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
|
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
|
{t("plex.info.cast")}
|
|
</h2>
|
|
<Carousel
|
|
ariaLabel={t("plex.info.cast")}
|
|
gap="gap-3"
|
|
keyboardNav={!!(onOpenPerson || onFilter)}
|
|
>
|
|
{cast.map((c, i) => {
|
|
const clickable = !!(onOpenPerson || onFilter);
|
|
const inner = (
|
|
<>
|
|
<div
|
|
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
|
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
|
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
|
>
|
|
{c.thumb ? (
|
|
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="grid h-full w-full place-items-center text-lg opacity-40">
|
|
{c.name.charAt(0)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
|
clickable ? "group-hover/cast:text-accent" : ""
|
|
}`}
|
|
>
|
|
{c.name}
|
|
</div>
|
|
{c.role && (
|
|
<div
|
|
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
|
|
>
|
|
{c.role}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
return clickable ? (
|
|
<Pressable
|
|
key={i}
|
|
align="center"
|
|
{...navItemAttrs(i)}
|
|
onClick={() =>
|
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
|
}
|
|
title={
|
|
onOpenPerson
|
|
? t("plex.info.viewPerson", { name: c.name })
|
|
: t("plex.info.filterActor", { name: c.name })
|
|
}
|
|
className="group/cast w-20 shrink-0"
|
|
>
|
|
{inner}
|
|
</Pressable>
|
|
) : (
|
|
<div key={i} className="w-20 shrink-0 text-center">
|
|
{inner}
|
|
</div>
|
|
);
|
|
})}
|
|
</Carousel>
|
|
</div>
|
|
)}
|
|
|
|
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
|
|
|
|
{/* 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) => (
|
|
<div key={col.id} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}>
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
|
{col.title}
|
|
</h2>
|
|
{onFilter && (
|
|
<Button
|
|
variant="link-muted"
|
|
className="text-xs"
|
|
onClick={() =>
|
|
onFilter({
|
|
collections: [col.id],
|
|
collectionTitles: { [col.id]: col.title },
|
|
})
|
|
}
|
|
>
|
|
{t("plex.info.browseCollection")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
|
{col.items.map((m, i) => (
|
|
<Pressable
|
|
key={m.id}
|
|
{...navItemAttrs(i)}
|
|
onClick={() => onPlayItem?.(m.id)}
|
|
disabled={!onPlayItem}
|
|
className="group/col w-28 shrink-0"
|
|
>
|
|
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-surface">
|
|
<img
|
|
src={m.thumb}
|
|
alt=""
|
|
className="h-full w-full object-cover transition group-hover/col:scale-105"
|
|
/>
|
|
{m.status === "watched" && (
|
|
<span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[0.5625rem] text-white">
|
|
✓
|
|
</span>
|
|
)}
|
|
{onPlayItem && (
|
|
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
|
|
<Play className="h-8 w-8 text-white" fill="currentColor" />
|
|
</div>
|
|
)}
|
|
{(m.position_seconds ?? 0) > 0 &&
|
|
m.status !== "watched" &&
|
|
m.duration_seconds ? (
|
|
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
|
|
<div
|
|
className="h-full bg-accent"
|
|
style={{
|
|
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
|
|
>
|
|
{m.title}
|
|
</div>
|
|
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
|
|
</Pressable>
|
|
))}
|
|
</Carousel>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{editorOpen && library && (
|
|
<PlexCollectionEditor
|
|
item={{ id: detail.id, title: detail.title }}
|
|
library={library}
|
|
memberOf={(detail.collections ?? []).map((c) => c.id)}
|
|
onClose={() => setEditorOpen(false)}
|
|
onChanged={() => onStateChange?.()}
|
|
/>
|
|
)}
|
|
{playlistOpen && (
|
|
<PlexPlaylistAdd
|
|
target={{ kind: "single", ratingKey: detail.id, title: detail.title }}
|
|
onClose={() => setPlaylistOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|