Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how they play — the groundwork for collecting HW-transcodable (or no-transcode) versions. Four classes derived from the stored `playable` + a new `transcode_hw` facet: - Direct Play (direct), Direct Stream (remux) - Transcode · HW — playable=transcode AND the iGPU can hardware-transcode it - Transcode · SW — playable=transcode, software libx264 only The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW. Backend: - New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at. Migration backfills transcode_hw codec-only so the filter works immediately. - `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the runtime encoder choice can't disagree by codec. - Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe). - Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON. Frontend: - Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity. - Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal overlay swallowed the cursor; the dot now sits above it (z-20). Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
804 lines
33 KiB
TypeScript
804 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,
|
|
tmdbBackdropUrl,
|
|
useCrossfadeBackdrop,
|
|
useDetailPrefs,
|
|
} from "../lib/plexDetailUi";
|
|
import { usePref } from "../lib/usePref";
|
|
import i18n from "../i18n";
|
|
import { Carousel } from "./Carousel";
|
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
|
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
|
|
|
// 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",
|
|
maximumFractionDigits: 1,
|
|
}).format(n);
|
|
|
|
// 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 Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
|
|
filterable && onFilterRating ? (
|
|
<button
|
|
onClick={() => onFilterRating(Math.floor(score))}
|
|
className="inline-flex items-center gap-1 hover:opacity-80"
|
|
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
|
>
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{score.toFixed(1)}
|
|
</button>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{score.toFixed(1)}
|
|
</span>
|
|
);
|
|
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} />
|
|
<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)} />
|
|
<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 ? (
|
|
<button
|
|
onClick={() => onOpenPerson(c.name)}
|
|
title={t("plex.info.viewPerson", { name: c.name })}
|
|
className="truncate text-left text-sm font-medium hover:text-accent"
|
|
>
|
|
{c.name}
|
|
</button>
|
|
) : (
|
|
<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 && (
|
|
<button
|
|
onClick={onClose}
|
|
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
|
aria-label={t("plex.player.close")}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</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}
|
|
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
|
|
>
|
|
<Play className="w-5 h-5 fill-current" />
|
|
{inProgress ? t("plex.resume") : t("plex.play")}
|
|
</button>
|
|
)}
|
|
{detail.status === "watched" ? (
|
|
<button
|
|
onClick={() => setState("new")}
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
{t("plex.info.markUnwatched")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setState("watched")}
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
{t("plex.info.markWatched")}
|
|
</button>
|
|
)}
|
|
{inProgress && (
|
|
<button
|
|
onClick={() => setState("new")}
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
{t("plex.info.clearResume")}
|
|
</button>
|
|
)}
|
|
{detail.kind === "movie" && (
|
|
<button
|
|
onClick={() => setPlaylistOpen(true)}
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
|
>
|
|
<ListPlus className="w-4 h-4" />
|
|
{t("plex.playlistAdd.manage")}
|
|
</button>
|
|
)}
|
|
{isAdmin && detail.kind === "movie" && library && (
|
|
<button
|
|
onClick={() => setEditorOpen(true)}
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
|
>
|
|
<FolderPlus className="w-4 h-4" />
|
|
{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">
|
|
{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 ? (
|
|
<button
|
|
key={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 text-center"
|
|
>
|
|
{inner}
|
|
</button>
|
|
) : (
|
|
<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
|
|
onClick={() =>
|
|
onFilter({
|
|
collections: [col.id],
|
|
collectionTitles: { [col.id]: col.title },
|
|
})
|
|
}
|
|
className="shrink-0 text-xs text-muted hover:text-accent"
|
|
>
|
|
{t("plex.info.browseCollection")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Carousel ariaLabel={col.title} gap="gap-3">
|
|
{col.items.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => onPlayItem?.(m.id)}
|
|
disabled={!onPlayItem}
|
|
className="group/col w-28 shrink-0 text-left"
|
|
>
|
|
<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}
|
|
</button>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|