siftlode/frontend/src/components/PlexBrowse.tsx
npeter83 b3af04a997 perf(plex): batch bulk pushes, cache show enrichment; remove dead code + DRY (F6/F8/F10)
F6: coalesce whole-show/season Plex watch-pushes into ONE background task
(push_bulk_state_to_plex) that reuses a single DB session + keep-alive Plex
client, instead of scheduling one task (own session + HTTP client) per episode.

F8: cache a show's live Plex enrichment (metadata + related) per rating_key for
a short TTL and fetch the two calls in parallel; repeat opens skip the network.
Raw payloads are cached; per-user shaping stays out. An empty related list is not
cached (plex.related swallows a transient failure as [] — don't pin it for the TTL).

F10: remove dead code — the pre-unified /browse route + browse(), the /people
route + people() + _person_photo(), api.plexPeople, interface PlexPerson, and the
orphaned plex.people i18n block. DRY: appendPlexFilters() shared by plexLibrary +
plexFacets; one exported plexDetailUi.Filterable (was Fil + Filterable); PlexInfo
migrated onto the shared plexDetailUi hooks/menu (DetailCustomizeMenu gained
overlay + extra props; useDetailPrefs exposes savePref; PrefToggle exported).

Reviewed (high) → clean. F7 (facet aggregate collapse) deferred: self-exclusion
gives each sub-aggregate a distinct WHERE, and no measurement shows /facets slow.
2026-07-11 03:10:02 +02:00

1205 lines
46 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import {
ArrowLeft,
Check,
CheckCheck,
CheckCircle2,
Film,
Info,
Layers,
ListPlus,
Play,
RotateCcw,
Star,
Tv2,
type LucideIcon,
} from "lucide-react";
import {
api,
EMPTY_PLEX_FILTERS,
plexFilterCount,
type PlexCard,
type PlexCastMember,
type PlexFilters,
type PlexSeasonDetail,
type PlexUnifiedResult,
} from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
const PlexInfo = lazy(() => import("./PlexInfo"));
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
type Sub =
| { kind: "grid" }
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "playlist"; id: number };
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
// search term comes from the shared top Header search box (q). A show drill-down rides history
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).
type Props = {
q: string;
onClearSearch: () => void;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
openPlaylist?: number | null;
onPlaylistOpened?: () => void;
};
const PAGE = 40;
function dur(n?: number | null): string {
if (!n) return "";
const h = Math.floor(n / 3600);
const m = Math.floor((n % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexBrowse({
q,
onClearSearch,
scope,
setScope,
show,
sort,
filters,
setFilters,
openPlaylist,
onPlaylistOpened,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" });
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
useEffect(() => {
if (openPlaylist != null) {
sub.open({ kind: "playlist", id: openPlaylist });
onPlaylistOpened?.();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
// Opening a show/player replaces the grid entirely (the component returns early below), so the
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
const scrollRef = useRef(0);
// Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
// grid, and browser Back returns to this same info page — restore where the user had scrolled it
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
// different title's info starts at the top.
const infoScrollRef = useRef(0);
const scroller = () => document.querySelector("main");
useLayoutEffect(() => {
const el = scroller();
if (!el) return;
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
// and never hijack Backspace while typing in a field.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return;
if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
e.preventDefault();
sub.back();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-library", scope, dq, sort, show, filters],
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexLibrary({
scope,
q: dq || undefined,
sort,
show,
filters,
offset: pageParam as number,
limit: PAGE,
}),
getNextPageParam: (last) => {
const seen = last.offset + last.items.length;
return seen < last.total ? seen : undefined;
},
});
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
const sentinel = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
useEffect(() => {
const el = sentinel.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: "600px" },
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function onCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
if (card.type === "show") sub.open({ kind: "show", id: card.id });
else sub.open({ kind: "player", id: card.id });
}
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
infoScrollRef.current = 0; // fresh info page starts at the top
sub.open({ kind: "info", id: card.id });
}
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
function optimisticStatus(id: string, next: string) {
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
old
? {
...old,
pages: old.pages.map((pg) => ({
...pg,
items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)),
episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)),
})),
}
: old,
);
}
async function toggleWatched(card: PlexCard) {
const next = card.status === "watched" ? "new" : "watched";
optimisticStatus(card.id, next);
await api.plexSetState(card.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
async function toggleEpisodeWatched(ep: PlexCard) {
const next = ep.status === "watched" ? "new" : "watched";
optimisticStatus(ep.id, next);
await api.plexSetState(ep.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
function applyFilter(patch: Partial<PlexFilters>) {
const merged = { ...filters } as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
const cur = merged[k];
if (Array.isArray(v) && Array.isArray(cur)) {
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
} else {
merged[k] = v;
}
}
setFilters(merged as unknown as PlexFilters);
if (patch.actors?.length || patch.directors?.length) setScope("both");
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
sub.open({ kind: "grid" });
}
if (sub.view.kind === "player") {
return (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
}
if (sub.view.kind === "playlist") {
const pid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexPlaylistView
playlistId={pid}
onBack={sub.back}
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
/>
</Suspense>
);
}
if (sub.view.kind === "info") {
const infoId = sub.view.id;
return (
<PlexInfoView
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id) => sub.open({ kind: "player", id })}
onFilter={applyFilter}
/>
);
}
if (sub.view.kind === "show") {
const showId = sub.view.id;
return (
<PlexShowView
showId={showId}
onBack={sub.back}
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
onOpenShow={(id) => sub.open({ kind: "show", id })}
onFilter={applyFilter}
/>
);
}
if (sub.view.kind === "season") {
const { showId, seasonId } = sub.view;
return (
<PlexSeasonView
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
/>
);
}
return (
<div className="p-4 max-w-[1600px] mx-auto">
<p className="text-xs text-muted mb-3">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p>
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 && episodes.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? (
// A search that comes up empty WHILE filters are active is usually the filters, not the
// query — say so and offer a one-click escape, instead of a bare "No matches".
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
<button
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
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.filter.clearAll")}
</button>
</div>
) : (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
)
) : (
<>
{/* Titles grid — movies + shows mixed (visually tagged). */}
{items.length > 0 && (
<>
{episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
onToggleWatched={toggleWatched}
/>
))}
</div>
</>
)}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep) => (
<EpisodeCard
key={ep.id}
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onToggleWatched={() => toggleEpisodeWatched(ep)}
/>
))}
</div>
</section>
)}
</>
)}
<div ref={sentinel} className="h-8" />
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
</div>
);
}
const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-emerald-500",
remux: "bg-amber-500",
transcode: "bg-orange-600",
};
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="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border 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-emerald-400" : "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-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded 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-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{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-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded 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 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 hover:text-accent">{card.title}</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</div>
</div>
);
}
function PlexInfoView({
id,
onBack,
onPlay,
onPlayItem,
onFilter,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
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.isLoading || !q.data ? (
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
) : (
<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}
onStateChange={() => q.refetch()}
/>
</Suspense>
)}
</div>
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached ["plex-show", 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.
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;
}
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>
);
}
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 ? "bg-accent text-accent-fg shadow-sm hover:opacity-90" : "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-[11px] font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
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="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border 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-emerald-400" : "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-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded 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-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{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>
);
}
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => 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.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 ${onFilter ? "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 ${onFilter ? "group-hover/cast:text-accent" : ""}`}>
{c.name}
</div>
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
</>
);
return onFilter ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={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>
);
}
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="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
<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-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{t("plex.watched")}
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{r.title}</div>
</button>
))}
</div>
</section>
);
}
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-emerald-400" : "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-emerald-400" />
</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-[11px] 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-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
{onAdd && (
<button
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1 text-muted transition hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}
function PlexShowView({
showId,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
onFilter,
}: {
showId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
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);
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
async function markSeason(seasonRk: string, w: boolean) {
await api.plexSeasonState(seasonRk, w).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
return (
<div className="p-4 max-w-[1200px] mx-auto">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
{/* Hero */}
<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-10">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
/>
</div>
<button
onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
disabled={!show.resume}
className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
title={show.status === "new" ? t("plex.play") : t("plex.resume")}
>
<img
src={show.thumb}
alt=""
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
/>
{show.resume && (
<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">
<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>}
{show.imdb_rating != null && (
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) }) : undefined}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</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-[11px] 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")} />
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markShowUnwatched") : 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>
</div>
</div>
{/* 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} />}
{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: ["plex-show", showId] })}
/>
)}
</div>
);
}
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 q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
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) ?? [];
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);
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
async function markEpisode(ep: PlexCard) {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
return (
<div className="p-4 max-w-[1200px] mx-auto">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
<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-10">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={false}
/>
</div>
<button
onClick={() => se.resume && onPlay(se.resume.id, seasonKeys)}
disabled={!se.resume}
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
>
<img
src={se.thumb}
alt=""
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
/>
{se.resume && (
<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">
<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, seasonKeys)}
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, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} />
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markSeasonUnwatched") : 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>
</div>
</div>
<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, seasonKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
))}
</div>
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
</div>
);
}