Files
siftlode/frontend/src/components/PlexInfo.tsx
T
peter 392099770a fix(plex): an episode Plex has no number for is named by its title alone
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.
2026-10-07 23:16:38 +02:00

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>
);
}