siftlode/frontend/src/components/PlexBrowse.tsx
npeter83 4dd1327b93 chore(hygiene): Phase 4 guardrails — noUnusedLocals/Parameters + dead-code sweep
Enable tsconfig noUnusedLocals + noUnusedParameters (permanent guardrail: every tsc/
build now flags re-accumulated dead code). Fixed the 8 violations that surfaced, all
genuine dead code:
- Sidebar: deleted unused SORT_IDS/SHOW_IDS/rollSeed consts + the dead Toggle component
  (+ its now-unused Switch import).
- PlexBrowse: dropped the unused onClearSearch prop (Props + destructure + App call site).
- VideoEditor: dropped the unused map index; notifications: deleted the dead back-compat
  toast(); storage: deleted the unused non-account usePersistedState (+ readJSON import).
- SavedViewsWidget: deleted the dead loadDefaultViewFilters export (App loads the default
  view inline; resolves the last knip unused-export → knip now clean).

Backend: added backend/ruff.toml (ignore E402 — intentional pre-import setup in main.py)
and fixed the 6 E702/E741 style items (_vtt_s_to_ts semicolons, ambiguous `l` loop vars)
so ruff is green. localdev boots; Feed/Plex/sidebar render; 0 console errors.
2026-07-12 02:15:52 +02:00

1195 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 { formatRuntime } from "../lib/format";
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;
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;
export default function PlexBrowse({
q,
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. The sentinel lives
// in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
// Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
useEffect(() => {
if (!sentinelEl) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: "600px" },
);
io.observe(sentinelEl);
return () => io.disconnect();
}, [sentinelEl, 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"] });
}
// 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={() => toggleWatched(ep)}
/>
))}
</div>
</section>
)}
</>
)}
<div ref={setSentinelEl} 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, formatRuntime(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}
// No onStateChange: PlexInfo.setState already invalidates ["plex-item", id] (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
</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">{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 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>
);
}