912 lines
34 KiB
TypeScript
912 lines
34 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 {
|
|
epLabel,
|
|
seasonTitle,
|
|
tmdbBackdropUrl,
|
|
useCrossfadeBackdrop,
|
|
useDetailPrefs,
|
|
} 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 (E30 S6). 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 (E30 S6, 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 (E30
|
|
// S6); 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;
|
|
// E5 S8: 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,
|
|
});
|
|
|
|
const section = (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 (E30 S6, 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 (E5 S6). */}
|
|
{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>
|
|
) : (
|
|
<>
|
|
{section("plex.person.movies", d.movies)}
|
|
{section("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();
|
|
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"));
|
|
} finally {
|
|
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 (E28 C1): 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}
|
|
/>
|
|
}
|
|
>
|
|
<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
|
|
}
|
|
>
|
|
{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 after the S6 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>
|
|
</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"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
}
|
|
async function markEpisode(ep: PlexCard) {
|
|
try {
|
|
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
|
|
} 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">
|
|
{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>
|
|
);
|
|
}
|