refactor(plex): split PlexBrowse god-file into cards + views + shared UI (E4 S4)
Pure behaviour-preserving code-motion — PlexBrowse 1751 -> 538 lines. The ~11 inlined components are self-contained (every view is invoked with shell callbacks as props; no view reaches back into shell state), so they just move to sibling files. New files: - lib/plexWatch.ts: toggleWatchState (shared by the grid + person page) - components/plexCards.tsx: PlexPosterCard, SeasonCard, CastStrip, RelatedStrip, EpisodeCard - components/plexViews.tsx: PlexInfoView, PlexPersonView, PlexShowView, PlexSeasonView - lib/plexDetailUi.tsx (extended): BackBtn, ActionBtn, WatchedToggle, DetailHero, epLabel (the file's own remit — "shared detail-page UI") PlexBrowse keeps the Sub router + PLEX_SUB_URL + PAGE + PlexBrowse() shell. Repointed the only external importer (PlexPersonView.dom.test) to plexCards/plexViews. Self-E2E (localdev): Plex grid renders (poster cards + episodes); The Expanse show-drill renders the hero + action buttons + season cards; ?v=show URL-mirroring intact; 0 console errors. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({
|
|||||||
plexFilterCount: () => 0,
|
plexFilterCount: () => 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { CastStrip, PlexPersonView } from "./PlexBrowse";
|
import { CastStrip } from "./plexCards";
|
||||||
|
import { PlexPersonView } from "./plexViews";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
||||||
|
import { type CSSProperties } from "react";
|
||||||
|
import { formatRuntime } from "../lib/format";
|
||||||
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
|
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||||
|
|
||||||
|
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
||||||
|
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
||||||
|
// all three stay visually distinct.
|
||||||
|
const PLAYABLE_TINT: Record<string, string> = {
|
||||||
|
direct: "bg-success",
|
||||||
|
remux: "bg-warn",
|
||||||
|
transcode: "bg-danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlexPosterCard({
|
||||||
|
card,
|
||||||
|
onOpen,
|
||||||
|
onInfo,
|
||||||
|
onToggleWatched,
|
||||||
|
}: {
|
||||||
|
card: PlexCard;
|
||||||
|
onOpen: () => void;
|
||||||
|
onInfo: () => void;
|
||||||
|
onToggleWatched: (c: PlexCard) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||||
|
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||||
|
const pct =
|
||||||
|
inProgress && card.duration_seconds
|
||||||
|
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group text-left"
|
||||||
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||||
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
|
||||||
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-white">
|
||||||
|
<Play className="w-10 h-10" fill="currentColor" />
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{card.type === "show"
|
||||||
|
? t("plex.openShow")
|
||||||
|
: inProgress
|
||||||
|
? t("plex.resume")
|
||||||
|
: t("plex.play")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||||
|
{isPlayable && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched(card);
|
||||||
|
}}
|
||||||
|
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Media info page (movies/episodes). */}
|
||||||
|
{isPlayable && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInfo();
|
||||||
|
}}
|
||||||
|
title={t("plex.info.title")}
|
||||||
|
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||||
|
{card.status === "watched" && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.type === "show" && card.season_count != null && (
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
|
{card.type === "show" && card.status === "in_progress" && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
|
{t("plex.inProgress")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.playable && (
|
||||||
|
<span
|
||||||
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||||
|
title={t(`plex.playable.${card.playable}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||||
|
<span
|
||||||
|
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
||||||
|
title={t(
|
||||||
|
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeasonCard({
|
||||||
|
se,
|
||||||
|
onOpen,
|
||||||
|
onToggleWatched,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
se: PlexSeasonDetail;
|
||||||
|
onOpen: () => void;
|
||||||
|
onToggleWatched?: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const watched = se.status === "watched";
|
||||||
|
return (
|
||||||
|
<div className="group text-left">
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={se.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-white">
|
||||||
|
<Play className="w-9 h-9" fill="currentColor" />
|
||||||
|
<span className="text-xs font-medium">{t("plex.openShow")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (whole season), on hover. */}
|
||||||
|
{onToggleWatched && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||||
|
}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Add whole season to a playlist, on hover. */}
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
|
title={t("plex.playlist.addSeason")}
|
||||||
|
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{watched && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{se.status === "in_progress" && (
|
||||||
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
|
{t("plex.inProgress")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
{se.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CastStrip({
|
||||||
|
cast,
|
||||||
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
|
}: {
|
||||||
|
cast: PlexCastMember[];
|
||||||
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="glass-card rounded-2xl 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.info.cast")}</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
|
{cast.map((c, i) => {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
|
||||||
|
>
|
||||||
|
{c.thumb ? (
|
||||||
|
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
{c.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return clickable ? (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() =>
|
||||||
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
onOpenPerson
|
||||||
|
? t("plex.info.viewPerson", { name: c.name })
|
||||||
|
: t("plex.info.filterActor", { name: c.name })
|
||||||
|
}
|
||||||
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="w-20 shrink-0 text-center">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelatedStrip({
|
||||||
|
related,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
related: PlexCard[];
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="glass-card rounded-2xl 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.related")}</h2>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{related.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => onOpen(r.id)}
|
||||||
|
className="group w-[130px] shrink-0 text-left"
|
||||||
|
>
|
||||||
|
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||||
|
<img
|
||||||
|
src={r.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
{r.status === "watched" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
||||||
|
{r.title}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EpisodeCard({
|
||||||
|
ep,
|
||||||
|
onPlay,
|
||||||
|
onAdd,
|
||||||
|
onToggleWatched,
|
||||||
|
withShowTitle,
|
||||||
|
}: {
|
||||||
|
ep: PlexCard;
|
||||||
|
onPlay: () => void;
|
||||||
|
onAdd?: () => void;
|
||||||
|
onToggleWatched?: () => void;
|
||||||
|
withShowTitle?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||||
|
const pct =
|
||||||
|
inProgress && ep.duration_seconds
|
||||||
|
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div className="group">
|
||||||
|
<div
|
||||||
|
onClick={onPlay}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onPlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={ep.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||||
|
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
{/* Quick watched toggle (single episode), on hover. */}
|
||||||
|
{onToggleWatched && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched();
|
||||||
|
}}
|
||||||
|
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{ep.status === "watched" && (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||||
|
onToggleWatched ? "group-hover:opacity-0" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{withShowTitle && ep.show_title && (
|
||||||
|
<div className="text-2xs text-muted truncate">
|
||||||
|
{ep.show_title}
|
||||||
|
{ep.season_number != null && ep.episode_number != null
|
||||||
|
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm font-medium leading-tight">
|
||||||
|
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||||
|
{ep.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||||
|
</div>
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
title={t("plex.playlist.addEpisode")}
|
||||||
|
aria-label={t("plex.playlist.addEpisode")}
|
||||||
|
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
|
import { pickRatingBadge } from "../lib/plexRating";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
|
import {
|
||||||
|
ActionBtn,
|
||||||
|
BackBtn,
|
||||||
|
DetailCustomizeMenu,
|
||||||
|
DetailHero,
|
||||||
|
epLabel,
|
||||||
|
Filterable,
|
||||||
|
useArtBackdrop,
|
||||||
|
useDetailPrefs,
|
||||||
|
WatchedToggle,
|
||||||
|
} from "../lib/plexDetailUi";
|
||||||
|
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||||
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
|
|
||||||
|
// 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 lib/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) => void;
|
||||||
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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,
|
||||||
|
onInfoCard,
|
||||||
|
onFilter,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenCard: (card: PlexCard) => void;
|
||||||
|
onInfoCard: (card: PlexCard) => void;
|
||||||
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
||||||
|
|
||||||
|
// 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>(qk.plexPerson(name), (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)),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
),
|
||||||
|
resync: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const section = (titleKey: string, cards: PlexCard[]) =>
|
||||||
|
cards.length > 0 ? (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<PlexPosterCard
|
||||||
|
key={c.id}
|
||||||
|
card={c}
|
||||||
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onInfo={() => onInfoCard(c)}
|
||||||
|
onToggleWatched={toggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const d = q.data;
|
||||||
|
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<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: headshot + name + a "filter the library by this person" action. */}
|
||||||
|
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center 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">
|
||||||
|
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||||
|
<p className="mb-2 text-xs text-muted">
|
||||||
|
{t("plex.person.credits", {
|
||||||
|
movies: d.movies.length,
|
||||||
|
shows: d.shows.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilter({ actors: [d.name] })}
|
||||||
|
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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[]) => void;
|
||||||
|
onOpenSeason: (seasonId: string) => void;
|
||||||
|
onOpenShow: (id: string) => void;
|
||||||
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
|
const lang = plexLang();
|
||||||
|
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 show = d?.show;
|
||||||
|
const showLib = show?.library ?? undefined;
|
||||||
|
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||||
|
const watched = show?.status === "watched";
|
||||||
|
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
|
||||||
|
useArtBackdrop(show?.art, artBg);
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[1200px] 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}
|
||||||
|
showCast={showCast}
|
||||||
|
onToggleCast={toggleCast}
|
||||||
|
hasCast={show.cast.length > 0}
|
||||||
|
showRelated={showRelated}
|
||||||
|
onToggleRelated={toggleRelated}
|
||||||
|
hasRelated={d.related.length > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||||
|
{show.year != null && (
|
||||||
|
<Filterable
|
||||||
|
onClick={
|
||||||
|
onFilter
|
||||||
|
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{show.year}
|
||||||
|
</Filterable>
|
||||||
|
)}
|
||||||
|
{show.content_rating && (
|
||||||
|
<Filterable
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
{/* decorative rating gold — intentionally NOT a status token */}
|
||||||
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{show.genres.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{show.genres.map((g) => (
|
||||||
|
<Filterable
|
||||||
|
key={g}
|
||||||
|
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||||
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{show.summary && (
|
||||||
|
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
|
||||||
|
)}
|
||||||
|
<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)}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={t("plex.series.playFromStart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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="glass-card rounded-2xl 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>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||||
|
{d.seasons.map((se) => (
|
||||||
|
<SeasonCard
|
||||||
|
key={se.id}
|
||||||
|
se={se}
|
||||||
|
onOpen={() => onOpenSeason(se.id)}
|
||||||
|
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||||
|
onAdd={
|
||||||
|
se.episodes.length > 0
|
||||||
|
? () =>
|
||||||
|
setAddTarget({
|
||||||
|
kind: "group",
|
||||||
|
ratingKeys: se.episodes.map((e) => e.id),
|
||||||
|
title: `${show.title} — ${se.title}`,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{show.cast.length > 0 && showCast && (
|
||||||
|
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||||
|
)}
|
||||||
|
{d.related.length > 0 && showRelated && (
|
||||||
|
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const lang = plexLang();
|
||||||
|
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 [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 = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||||
|
const watched = se?.status === "watched";
|
||||||
|
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
|
||||||
|
useArtBackdrop(d?.show.art, artBg);
|
||||||
|
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() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[1200px] 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}
|
||||||
|
showCast={showCast}
|
||||||
|
onToggleCast={toggleCast}
|
||||||
|
hasCast={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm text-muted hover:text-accent transition text-left"
|
||||||
|
>
|
||||||
|
{d.show.title}
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||||
|
<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)}
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={t("plex.series.playFromStart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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} — ${se.title}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon={ListPlus}
|
||||||
|
label={t("plex.playlist.addSeason")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailHero>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
|
{se.episodes.map((ep) => (
|
||||||
|
<EpisodeCard
|
||||||
|
key={ep.id}
|
||||||
|
ep={ep}
|
||||||
|
onPlay={() => onPlay(ep.id, allKeys)}
|
||||||
|
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
|
||||||
|
onToggleWatched={() => markEpisode(ep)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
Play,
|
||||||
|
SlidersHorizontal,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useDismiss } from "./useDismiss";
|
import { useDismiss } from "./useDismiss";
|
||||||
import { usePref, useSavePref } from "./usePref";
|
import { usePref, useSavePref } from "./usePref";
|
||||||
|
import type { PlexCard } from "./api";
|
||||||
|
|
||||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||||
@@ -184,3 +192,130 @@ export function PrefToggle({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||||||
|
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||||||
|
|
||||||
|
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||||
|
if (!ep) return undefined;
|
||||||
|
if (ep.season_number != null && ep.episode_number != null)
|
||||||
|
return `S${ep.season_number} · E${ep.episode_number}`;
|
||||||
|
return ep.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionBtn({
|
||||||
|
onClick,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
primary,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
primary?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
||||||
|
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex flex-col items-start leading-tight">
|
||||||
|
<span>{label}</span>
|
||||||
|
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||||||
|
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||||||
|
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||||||
|
* `children`. Poster is slightly larger on the show page (default) than the season page. */
|
||||||
|
export function DetailHero({
|
||||||
|
thumb,
|
||||||
|
posterTitle,
|
||||||
|
posterDisabled,
|
||||||
|
showPlayOverlay,
|
||||||
|
onPosterClick,
|
||||||
|
posterSizeClass = "w-32 sm:w-44",
|
||||||
|
customizeMenu,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
thumb: string;
|
||||||
|
posterTitle: string;
|
||||||
|
posterDisabled: boolean;
|
||||||
|
showPlayOverlay: boolean;
|
||||||
|
onPosterClick: () => void;
|
||||||
|
posterSizeClass?: string;
|
||||||
|
customizeMenu: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||||
|
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||||
|
<button
|
||||||
|
onClick={onPosterClick}
|
||||||
|
disabled={posterDisabled}
|
||||||
|
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||||
|
title={posterTitle}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||||
|
/>
|
||||||
|
{showPlayOverlay && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||||
|
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||||
|
* the show and season heroes; the labels differ, so the caller passes both. */
|
||||||
|
export function WatchedToggle({
|
||||||
|
watched,
|
||||||
|
busy,
|
||||||
|
onToggle,
|
||||||
|
watchedLabel,
|
||||||
|
unwatchedLabel,
|
||||||
|
}: {
|
||||||
|
watched: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
watchedLabel: string;
|
||||||
|
unwatchedLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={busy}
|
||||||
|
icon={watched ? CheckCheck : Check}
|
||||||
|
label={watched ? watchedLabel : unwatchedLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { api, type PlexCard } from "./api";
|
||||||
|
import { notifyActionError } from "./actionErrors";
|
||||||
|
|
||||||
|
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||||
|
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||||
|
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
|
||||||
|
// browse grid and the person page, each of which owns a different cache shape.
|
||||||
|
export async function toggleWatchState(
|
||||||
|
card: PlexCard,
|
||||||
|
t: TFunction,
|
||||||
|
handlers: { optimistic: (next: string) => void; resync: () => void },
|
||||||
|
) {
|
||||||
|
const next = card.status === "watched" ? "new" : "watched";
|
||||||
|
handlers.optimistic(next);
|
||||||
|
try {
|
||||||
|
await api.plexSetState(card.id, next);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
}
|
||||||
|
handlers.resync();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user