The OS media title printed the missing number as "Enull", the player and info headers as a bare "E". One helper for the three lines.
639 lines
26 KiB
TypeScript
639 lines
26 KiB
TypeScript
import { useState } 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 PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
|
import { formatCompact, formatDecimal, formatRuntime } from "../lib/format";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
|
import {
|
|
DETAIL_POSTER,
|
|
episodeLine,
|
|
tmdbBackdropUrl,
|
|
useCrossfadeBackdrop,
|
|
useDetailAside,
|
|
useDetailPrefs,
|
|
} from "../lib/plexDetail";
|
|
import { Carousel } from "./ui/Carousel";
|
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
|
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { Badge } from "./ui/Badge";
|
|
import { IconLink } from "./ui/LinkButton";
|
|
import { PageHeading } from "./ui/Headings";
|
|
import { CastRow, PlexPosterCard } from "./plexCards";
|
|
import { PlexMenu } from "./PlexMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { usePlexCardActions } from "./usePlexCardActions";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import { vocab } from "../lib/vocab";
|
|
|
|
// 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) render
|
|
// on the page variant only; the overlay stays lean.
|
|
|
|
// 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, i18n } = useTranslation();
|
|
const shown = formatDecimal(score, i18n.language);
|
|
return filterable && onFilterRating ? (
|
|
<Pressable
|
|
onClick={() => onFilterRating(Math.floor(score))}
|
|
className="inline-flex min-h-6 items-center gap-1 hover:underline touch-target"
|
|
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
|
>
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{shown}
|
|
</Pressable>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Star className="h-3.5 w-3.5 fill-current" />
|
|
{shown}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Multi-source ratings cluster: 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,
|
|
onDark,
|
|
}: {
|
|
imdbRating?: number | null;
|
|
imdbUrl?: string | null;
|
|
audience?: number | null;
|
|
tmdbRating?: number | null;
|
|
tmdbVotes?: number | null;
|
|
onFilterRating?: (score: number) => void;
|
|
/** Over the always-dark player: the page's `--rating` is tuned for the theme's page, so a light
|
|
* theme's dark gold would sit on the player's black (2.6:1, measured). */
|
|
onDark?: boolean;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const primary = imdbRating ?? audience ?? null; // the filterable score
|
|
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
|
|
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
|
onDark ? "bg-rating-media/15 text-rating-media" : "bg-rating/15 text-rating"
|
|
}`;
|
|
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">IMDb</span>
|
|
</span>
|
|
)}
|
|
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
|
|
{has(imdbRating) && imdbUrl && (
|
|
<IconLink
|
|
href={imdbUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
label={t("plex.info.openImdb")}
|
|
variant={onDark ? "overlay" : "ghost"}
|
|
className="-ml-1.5"
|
|
>
|
|
<ExternalLink />
|
|
</IconLink>
|
|
)}
|
|
{has(tmdbRating) && (
|
|
<span className={chipCls}>
|
|
<Score score={tmdbRating} filterable={false} />
|
|
<span className="text-2xs font-normal">
|
|
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{has(audience) && imdbRating !== audience && (
|
|
<span className={chipCls}>
|
|
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
|
<span className="text-2xs font-normal">{t("plex.info.audience")}</span>
|
|
</span>
|
|
)}
|
|
</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 (
|
|
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-muted"}`} />
|
|
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
|
{sub && <span className="opacity-70">{sub}</span>}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
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: the page opens its player, the overlay plays it
|
|
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
|
|
onPlayItem: (id: string, fromStart?: boolean) => 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, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
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 TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
|
|
const aside = useDetailAside(detail);
|
|
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, and its collection siblings' art. 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?.();
|
|
};
|
|
|
|
// A collection sibling is a Plex card like any other: the related strip's menu, its
|
|
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
|
|
// is; the resync corrects it to the truth either way.
|
|
const colMenu = useContextMenu<PlexCard>();
|
|
const toggleColWatched = (card: PlexCard) =>
|
|
toggleWatchState(card, t, {
|
|
optimistic: (next) =>
|
|
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
collections: old.collections?.map((col) => ({
|
|
...col,
|
|
items: col.items.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
|
})),
|
|
}
|
|
: old,
|
|
),
|
|
resync: () => {
|
|
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
|
qc.invalidateQueries({ queryKey: qk.plexItem(card.id) });
|
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
},
|
|
});
|
|
const colActions = usePlexCardActions(
|
|
{
|
|
onOpen: (c) => onPlayItem(c.id),
|
|
onRestart: (c) => onPlayItem(c.id, true),
|
|
onToggleWatched: toggleColWatched,
|
|
},
|
|
colMenu.open,
|
|
);
|
|
|
|
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() });
|
|
|
|
const asideShown = !overlay && aside.column;
|
|
const infoTitle =
|
|
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
|
|
|
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 ? "" : `${SURFACE.panel} @container 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">
|
|
{/* The page's own h1; over the running player it is the info dialog's title. */}
|
|
{overlay ? (
|
|
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
|
) : (
|
|
<PageHeading className="text-2xl font-bold">{infoTitle}</PageHeading>
|
|
)}
|
|
{detail.kind === "episode" && (
|
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
|
{episodeLine(detail)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DetailCustomizeMenu
|
|
overlay={overlay}
|
|
artBg={artBg}
|
|
onToggleArtBg={toggleArtBg}
|
|
crossfade={crossfade}
|
|
onToggleCrossfade={toggleCrossfade}
|
|
showCast={showCast}
|
|
onToggleCast={toggleCast}
|
|
hasCast={detail.cast.length > 0}
|
|
extra={
|
|
<>
|
|
{!overlay && <DetailAsideToggles aside={aside} />}
|
|
{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>
|
|
|
|
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
|
|
<div
|
|
className={
|
|
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
|
|
}
|
|
>
|
|
<div className="flex gap-4 sm:gap-6">
|
|
{/* Poster */}
|
|
<img
|
|
src={detail.thumb}
|
|
alt=""
|
|
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} 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
|
|
tag
|
|
onDark={overlay}
|
|
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
|
|
tag
|
|
onDark={overlay}
|
|
onClick={
|
|
onFilter
|
|
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
|
: undefined
|
|
}
|
|
>
|
|
{vocab(t, "certificate", detail.content_rating)}
|
|
</Filterable>
|
|
)}
|
|
{detail.duration_seconds ? (
|
|
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
|
) : null}
|
|
{detail.studio && (
|
|
<Filterable
|
|
tag
|
|
onDark={overlay}
|
|
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
|
>
|
|
{detail.studio}
|
|
</Filterable>
|
|
)}
|
|
<PlaybackChip detail={detail} overlay={overlay} />
|
|
</div>
|
|
|
|
{/* Multi-source ratings row: 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}
|
|
onDark={overlay}
|
|
/>
|
|
</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}
|
|
tag
|
|
onDark={overlay}
|
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
|
>
|
|
{g}
|
|
</Filterable>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{detail.directors.length > 0 && (
|
|
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
|
<span>{t("plex.info.director")}:</span>
|
|
{detail.directors.map((d) => (
|
|
<Filterable
|
|
key={d}
|
|
tag
|
|
onDark={overlay}
|
|
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
|
>
|
|
{d}
|
|
</Filterable>
|
|
))}
|
|
</p>
|
|
)}
|
|
|
|
{detail.summary && (
|
|
<p
|
|
className={`measure 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>
|
|
)}
|
|
{!overlay && aside.shown.keywords && (
|
|
<DetailKeywords
|
|
keywords={detail.tmdb?.keywords ?? []}
|
|
onFilter={onFilter}
|
|
className="mt-5"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{asideShown && (
|
|
<DetailAside
|
|
facts={detail}
|
|
shown={aside.shown}
|
|
onFilter={onFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* End hero panel. */}
|
|
|
|
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
|
|
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
|
|
sat beside them moved up into the hero's second column. The overlay (over
|
|
the running player) is the same stack, lean. */}
|
|
{(() => {
|
|
const collections = (detail.collections ?? []).filter(
|
|
(col) => !hiddenSources.includes(col.source),
|
|
);
|
|
const strips = collections.map((col) => (
|
|
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} 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>
|
|
{col.items.map((m, i) => (
|
|
// The one media card, as on every other strip. A grid cell, so the
|
|
// card fills the slot the carousel stretches to its tallest card.
|
|
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
|
<PlexPosterCard
|
|
card={{ ...m, type: "movie" }}
|
|
shelf
|
|
navIndex={i}
|
|
onOpen={() => onPlayItem(m.id)}
|
|
onRestart={() => onPlayItem(m.id, true)}
|
|
onToggleWatched={toggleColWatched}
|
|
onAdd={colActions.add}
|
|
menuProps={colMenu.triggerProps}
|
|
menuButtonProps={colMenu.buttonProps}
|
|
menuOpen={colMenu.open && colMenu.target?.id === m.id}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
</div>
|
|
));
|
|
const castRow = cast.length > 0 && (
|
|
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
|
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
|
{t("plex.info.cast")}
|
|
</h2>
|
|
<CastRow
|
|
cast={cast}
|
|
tone={overlay ? "overlay" : "panel"}
|
|
onFilter={onFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
/>
|
|
</div>
|
|
);
|
|
if (overlay)
|
|
return (
|
|
<>
|
|
{strips}
|
|
{castRow}
|
|
</>
|
|
);
|
|
if (strips.length === 0 && !castRow) return null;
|
|
return (
|
|
<div className="flex min-w-0 flex-col gap-3">
|
|
{strips}
|
|
{castRow}
|
|
</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)}
|
|
/>
|
|
)}
|
|
<PlexMenu menu={colMenu} actions={colActions} />
|
|
</div>
|
|
);
|
|
}
|