Files
siftlode/frontend/src/components/plexViews.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

938 lines
35 KiB
TypeScript

import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Check,
Layers,
ListPlus,
Play,
RotateCcw,
Star,
Tv2,
Undo2,
User,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
api,
type PlexCard,
type PlexFilters,
type PlexPersonDetail,
type PlexSeasonDetail,
} from "../lib/api";
import { plexLang } from "../i18n";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import { toggleWatchState } from "../lib/plexWatch";
import { showEpisodeKeys } from "../lib/plexQueue";
import {
ActionBtn,
BackBtn,
DetailCustomizeMenu,
DetailHero,
Filterable,
WatchedToggle,
} from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
epLabel,
seasonTitle,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
useDetailAside,
} from "../lib/plexDetail";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { useCopyItems } from "./copyItems";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { Button } from "./ui/Button";
import { ClampedText } from "./ui/ClampedText";
import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { vocab } from "../lib/vocab";
import { formatDate, formatDecimal } from "../lib/format";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
// detail-page UI from plexDetailUi.
// Lazy: the rich movie info panel loads only when an info page is opened.
const PlexInfo = lazy(() => import("./PlexInfo"));
export function PlexInfoView({
id,
onBack,
onPlay,
onPlayItem,
onFilter,
onOpenPerson,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string, fromStart?: boolean) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = plexLang(i18n.language);
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-400 mx-auto">
<div className="p-4 pb-0">
<Button variant="glass" size="xs" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</Button>
</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<LoadingState label={t("plex.loading")} />
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo
detail={q.data}
variant="page"
library={q.data.library ?? undefined}
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
</Suspense>
)}
</div>
);
}
// How often, and for how long, a person page asks again for a bio that is on its way. The
// background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one.
const BIO_POLL_MS = 1500;
// From this many "Known for" titles the rail gets half the hero. Fewer (about one person in five) sit
// right beside the text, at their posters' width, and what is left over is the panel's end — not a hole
// between the text and a lone poster at the far edge (owner pick "b").
const KNOWN_FOR_HALF = 4;
const BIO_WAIT_MS = 20_000;
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
export function PlexPersonView({
name,
onBack,
onOpenCard,
onRestartCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onRestartCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
// lists on one screen, and the popover anchors where it opened, so a second controller would only
// mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const lang = plexLang(i18n.language);
const personKey = qk.plexPerson(name, lang);
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens;
// the answer says so (`enriching`) and the page asks again until it lands — for a while, not
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
// once (the next open tries again). The page is keyed on the person, so the wait starts per person;
// a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing.
const [waitOver, setWaitOver] = useState(false);
const [waitRound, setWaitRound] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
return () => clearTimeout(timer);
}, [waitRound]);
const q = useQuery({
queryKey: personKey,
queryFn: () => api.plexPerson(name, lang),
refetchInterval: (query) =>
query.state.data?.enriching && !waitOver && query.state.status !== "error"
? BIO_POLL_MS
: false,
});
const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError;
// Cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
const pd = q.data;
useCrossfadeBackdrop(
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
artBg,
crossfade,
);
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
old
? {
...old,
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
known_for: old.known_for.map((k) =>
k.id === card.id ? { ...k, status: next } : k,
),
}
: old,
),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle },
menu.open,
);
const cardMenu = (c: PlexCard) => ({
onRestart: () => onRestartCard(c),
onAdd: actions.add,
menuProps: menu.triggerProps,
menuButtonProps: menu.buttonProps,
menuOpen: menu.open && menu.target === c,
});
// Not `section`: a local named like the <section> it renders confused the React Compiler's scope
// analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
const renderSection = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
{cards.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onToggleWatched={toggle}
{...cardMenu(c)}
/>
))}
</NavGrid>
</section>
) : null;
const d = q.data;
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
return (
<div className="w-[90%] max-w-400 mx-auto">
<div className="p-4 pb-0">
<Button variant="glass" size="xs" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</Button>
</div>
{q.isError && !d ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<div className="p-4">
{/* Hero (owner pick "A"): the person — headshot, facts, a clamped bio — on the left,
what they are known for on the right, so the bio doesn't sit in a full-width panel with an
empty half beside it. One column where the panel is narrow. */}
<section className={`${SURFACE.panel} @container p-4 sm:p-5 mb-6`}>
<div
className={
d.known_for.length >= KNOWN_FOR_HALF
? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
: d.known_for.length > 0
? "grid gap-6 @3xl:grid-cols-[auto_auto] @3xl:justify-start"
: undefined
}
>
{/* The person's column is at most the bio's reading measure (text-sm, as the bio is), so a
short rail can sit right beside it. */}
<div className="measure min-w-0 text-sm">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
{d.known_for_department && (
<p className="text-xs font-medium text-accent">
{vocab(t, "department", d.known_for_department)}
</p>
)}
<p className="mt-1 mb-2 text-xs text-muted">
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
</p>
<div className="mb-2 space-y-0.5 text-xs text-muted">
{(d.birthday || d.place_of_birth) && (
<p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
{[formatDate(d.birthday, lang), d.place_of_birth]
.filter(Boolean)
.join(" · ")}
</p>
)}
{d.deathday && (
<p>
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
{formatDate(d.deathday, lang)}
</p>
)}
{d.also_known_as.length > 0 && (
<p className="truncate">
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
{d.also_known_as.slice(0, 4).join(", ")}
</p>
)}
</div>
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
{t("plex.person.filterBy", { name: d.name })}
</Chip>
</div>
</div>
{d.biography ? (
<ClampedText
text={d.biography}
lines={8}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-4"
/>
) : bioOnItsWay ? (
<LoadingState shape="text" label={t("plex.person.bioLoading")} className="mt-4" />
) : (
// An ask that failed while the bio was on its way is said, not left looking like a
// person with no bio; the retry asks (and waits) again.
d.enriching &&
q.isError && (
<StateMessage
tone="error"
compact
onRetry={() => {
setWaitOver(false);
setWaitRound((n) => n + 1);
void q.refetch();
}}
>
{t("plex.person.bioFailed")}
</StateMessage>
)
)}
</div>
{/* "Known for" — the person's most-popular titles that are in our library. */}
{d.known_for.length > 0 && (
<div className="min-w-0">
<Carousel
title={t("plex.person.knownFor")}
ariaLabel={t("plex.person.knownFor")}
keyboardNav
>
{d.known_for.map((c, i) => (
<div key={c.id} className="grid w-32 shrink-0 grid-cols-1">
<PlexPosterCard
shelf
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onToggleWatched={toggle}
{...cardMenu(c)}
/>
</div>
))}
</Carousel>
</div>
)}
</div>
</section>
{empty ? (
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{renderSection("plex.person.movies", d.movies)}
{renderSection("plex.person.shows", d.shows)}
</>
)}
</div>
)}
<PlexMenu menu={menu} actions={actions} />
</div>
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
// Add the whole show to a collection.
export function PlexShowView({
showId,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
onFilter,
onOpenPerson,
}: {
showId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const relatedMenu = useContextMenu<PlexCard>();
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
const seasonMenu = useContextMenu<PlexSeasonDetail>();
const copy = useCopyItems();
const show = d?.show;
const showLib = show?.library ?? undefined;
const allKeys = showEpisodeKeys(d);
const watched = show?.status === "watched";
const {
artBg,
showCast,
showRelated,
crossfade,
toggleArtBg,
toggleCast,
toggleRelated,
toggleCrossfade,
} = useDetailPrefs();
// The facts / keywords / crew: TMDB enriches shows too, and the page showed none of it.
const aside = useDetailAside(show ?? {});
useCrossfadeBackdrop(
[
show?.art,
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(d?.related ?? []).map((r) => r.art),
],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markSeason(seasonRk: string, w: boolean) {
try {
await api.plexSeasonState(seasonRk, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A season's add target (none for an empty season, which has nothing to add).
const addSeason = (se: PlexSeasonDetail) =>
show && se.episodes.length > 0
? () =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${seasonTitle(se, t)}`,
})
: undefined;
// A season's one action list: the rows its card shows on hover, plus copy.
const seasonItems = (se: PlexSeasonDetail) => {
const seWatched = se.status === "watched";
const add = addSeason(se);
return [
{
id: "open",
label: t("plex.series.openSeason"),
icon: <Tv2 className="w-4 h-4" />,
onSelect: () => onOpenSeason(se.id),
},
{
id: "watched",
label: seWatched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched"),
icon: seWatched ? <Undo2 className="w-4 h-4" /> : <Check className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void markSeason(se.id, !seWatched),
},
...(add
? [
{
id: "playlist",
label: t("plex.playlist.addSeason"),
icon: <ListPlus className="w-4 h-4" />,
onSelect: add,
},
]
: []),
...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })),
];
};
return (
<div className="p-4 max-w-300 mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
{/* Hero */}
<DetailHero
thumb={show.thumb}
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!show.resume}
showPlayOverlay={!!show.resume}
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
extra={<DetailAsideToggles aside={aside} />}
/>
}
aside={
aside.column && (
<DetailAside
facts={show}
shown={aside.shown}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
)
}
>
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
text, spaced by the gap alone (a '·' between chips reads as noise). */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
{show.year != null && (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable
tag
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
{vocab(t, "certificate", show.content_rating)}
</Filterable>
)}
{show.season_count != null && (
<span>{t("plex.seasons", { count: show.season_count })}</span>
)}
{(() => {
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
// with no imdb:// entry (imdb_rating NULL since the IMDb-only change) still shows a
// rating instead of dropping the badge entirely.
const badge = pickRatingBadge(show.imdb_rating, show.rating);
if (!badge) return null;
const { score } = badge;
return (
<Pressable
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
title={
onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
>
{/* the rating gold — its own token, not --warn: a score is not a warning */}
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{formatDecimal(score, lang)}
</Pressable>
);
})()}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<Filterable
key={g}
tag
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{show.summary && (
<ClampedText
text={show.summary}
lines={5}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-3"
/>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys, true)}
icon={RotateCcw}
label={t("plex.series.showFromStart")}
sub={epLabel(show.first)}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markShowUnwatched")}
unwatchedLabel={t("plex.series.markShowWatched")}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
{aside.shown.keywords && (
<DetailKeywords
keywords={show.tmdb?.keywords ?? []}
onFilter={onFilter}
className="mt-5"
/>
)}
</DetailHero>
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(8.75rem,1fr))] gap-3">
{d.seasons.map((se, i) => (
<SeasonCard
key={se.id}
navIndex={i}
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
onAdd={addSeason(se)}
menuProps={seasonMenu.triggerProps}
menuButtonProps={seasonMenu.buttonProps}
menuOpen={seasonMenu.open && seasonMenu.target === se}
/>
))}
</NavGrid>
</section>
{show.cast.length > 0 && showCast && (
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
)}
{d.related.length > 0 && showRelated && (
<RelatedStrip
related={d.related}
onOpen={(c) => onOpenShow(c.id)}
menuProps={relatedMenu.triggerProps}
menuButtonProps={relatedMenu.buttonProps}
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
/>
)}
</>
)}
<PlexMenu menu={relatedMenu} actions={related} />
<ContextMenu
menu={seasonMenu}
ariaLabel={t("plex.menu")}
items={seasonMenu.target ? seasonItems(seasonMenu.target) : []}
/>
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && showLib && (
<PlexCollectionEditor
item={{ id: show.id, title: show.title }}
library={showLib}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
/>
)}
</div>
);
}
export function PlexSeasonView({
showId,
seasonId,
onBack,
onPlay,
}: {
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const epMenu = useContextMenu<PlexCard>();
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [busy, setBusy] = useState(false);
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
// straight into the next season's first — the same cross-season order the show page already uses.
// seasonKeys stays only for the season-scoped "add to playlist" action.
const allKeys = showEpisodeKeys(d);
const watched = se?.status === "watched";
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
useCrossfadeBackdrop(
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
setBusy(false);
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markEpisode(ep: PlexCard) {
const next = ep.status === "watched" ? "new" : "watched";
try {
await api.plexSetState(ep.id, next);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin).
const episodes = usePlexCardActions(
{
onOpen: (ep) => onPlay(ep.id, allKeys),
onRestart: (ep) => onPlay(ep.id, allKeys, true),
onToggleWatched: (ep) => void markEpisode(ep),
},
epMenu.open,
);
return (
<div className="p-4 max-w-300 mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={se.thumb}
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!se.resume}
showPlayOverlay={!!se.resume}
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
posterSizeClass="w-28 sm:w-40"
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={false}
/>
}
>
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<PageHeading
className="text-2xl font-semibold leading-tight"
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
>
{seasonTitle(se, t)}
</PageHeading>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, allKeys, true)}
icon={RotateCcw}
label={t("plex.series.seasonFromStart")}
sub={epLabel(se.first)}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markSeasonUnwatched")}
unwatchedLabel={t("plex.series.markSeasonWatched")}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${seasonTitle(se, t)}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
</DetailHero>
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onRestart={() => onPlay(ep.id, allKeys, true)}
onAdd={() => episodes.add(ep)}
onToggleWatched={() => markEpisode(ep)}
menuProps={epMenu.triggerProps}
menuButtonProps={epMenu.buttonProps}
menuOpen={epMenu.open && epMenu.target === ep}
/>
))}
</NavGrid>
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
<PlexMenu menu={epMenu} actions={episodes} pin={false} />
</div>
);
}