feat(plex): unify movies + shows into one cross-library browser
Collapse the separate Movie/Show library sections into ONE unified library with a shared search + shared filter sidebar and a Movies/Shows/Both scope selector. Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user watch-state (a show's state = the aggregate of its episodes). On a search that also matches episodes, matching episodes come back in a separate 'episodes' list (the grouped 'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's libraries). /item and /show now return their library section key (for the admin collection editor, since there's no single library prop in the unified view). Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets + browse follow the scope (App's plexLib repurposed to a validated scope, default both). PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a clickable title (not just the poster), and a movie/show type tag. The quick watched toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/ content-rating) are clickable filters; clicking a person widens the scope to Both so the result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de). Still pending from the polish list (next pass): season-card quick toggles (watched + add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast customize menu on the series pages.
This commit is contained in:
parent
11b7558c6c
commit
736db017e4
9 changed files with 579 additions and 269 deletions
|
|
@ -1,17 +1,19 @@
|
|||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 {
|
||||
|
|
@ -21,8 +23,8 @@ import {
|
|||
type PlexCard,
|
||||
type PlexCastMember,
|
||||
type PlexFilters,
|
||||
type PlexPerson,
|
||||
type PlexSeasonDetail,
|
||||
type PlexUnifiedResult,
|
||||
} from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
|
@ -51,7 +53,8 @@ type Sub =
|
|||
type Props = {
|
||||
q: string;
|
||||
onClearSearch: () => void;
|
||||
library: string;
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
setScope: (v: string) => void;
|
||||
show: string;
|
||||
sort: string;
|
||||
filters: PlexFilters;
|
||||
|
|
@ -73,7 +76,8 @@ function dur(n?: number | null): string {
|
|||
export default function PlexBrowse({
|
||||
q,
|
||||
onClearSearch,
|
||||
library,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
sort,
|
||||
filters,
|
||||
|
|
@ -133,12 +137,12 @@ export default function PlexBrowse({
|
|||
}, [sub]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
||||
enabled: !!library && sub.view.kind === "grid",
|
||||
queryKey: ["plex-library", scope, dq, sort, show, filters],
|
||||
enabled: !!scope && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({
|
||||
library,
|
||||
api.plexLibrary({
|
||||
scope,
|
||||
q: dq || undefined,
|
||||
sort,
|
||||
show,
|
||||
|
|
@ -154,21 +158,8 @@ export default function PlexBrowse({
|
|||
|
||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||
|
||||
// Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
|
||||
// adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else).
|
||||
const peopleQ = useQuery({
|
||||
queryKey: ["plex-people", library, dq],
|
||||
queryFn: () => api.plexPeople(library, dq),
|
||||
enabled: !!library && dq.length >= 2 && sub.view.kind === "grid",
|
||||
});
|
||||
const people = peopleQ.data?.people ?? [];
|
||||
function addPerson(p: PlexPerson) {
|
||||
const key = p.kind === "director" ? "directors" : "actors";
|
||||
const cur = filters[key];
|
||||
if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] });
|
||||
onClearSearch(); // switch from the name search to the person filter (clears the search box)
|
||||
}
|
||||
// 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);
|
||||
|
|
@ -197,8 +188,40 @@ export default function PlexBrowse({
|
|||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
async function toggleWatched(card: PlexCard) {
|
||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
// Optimistically flip the card in every cached library page so the quick toggle reacts instantly
|
||||
// and reliably BOTH ways (mark and un-mark), then reconcile with the server.
|
||||
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
pages: old.pages.map((pg) => ({
|
||||
...pg,
|
||||
items: pg.items.map((it) => (it.id === card.id ? { ...it, status: next } : it)),
|
||||
})),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
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") {
|
||||
|
|
@ -225,28 +248,10 @@ export default function PlexBrowse({
|
|||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
library={library}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||
onFilter={(patch) => {
|
||||
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
|
||||
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
|
||||
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
|
||||
// info page instead of leaving the Plex module.
|
||||
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);
|
||||
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
|
||||
sub.open({ kind: "grid" });
|
||||
}}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -255,11 +260,11 @@ export default function PlexBrowse({
|
|||
return (
|
||||
<PlexShowView
|
||||
showId={showId}
|
||||
library={library}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -281,48 +286,9 @@ export default function PlexBrowse({
|
|||
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||
</p>
|
||||
|
||||
{/* Virtual person cards for a name search — click to add them to the filter. */}
|
||||
{people.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted mb-2">{t("plex.people.match")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{people.map((p) => {
|
||||
const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
|
||||
return (
|
||||
<button
|
||||
key={`${p.kind}:${p.name}`}
|
||||
onClick={() => addPerson(p)}
|
||||
disabled={added}
|
||||
className={`flex items-center gap-2.5 rounded-xl border p-1.5 pr-3 text-left transition ${
|
||||
added ? "border-accent bg-accent/10" : "border-border bg-card hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{p.photo ? (
|
||||
<img src={p.photo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-sm opacity-40">
|
||||
{p.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{p.name}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{t(`plex.people.${p.kind}`)} · {t("plex.people.count", { count: p.count })}
|
||||
{added ? ` · ${t("plex.people.added")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{browseQ.isLoading ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
) : 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".
|
||||
|
|
@ -339,17 +305,38 @@ export default function PlexBrowse({
|
|||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
<>
|
||||
{/* 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)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={sentinel} className="h-8" />
|
||||
|
|
@ -407,16 +394,14 @@ function PlexPosterCard({
|
|||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Hover affordance: what a click does (play / resume / open show). */}
|
||||
{/* 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">
|
||||
{isPlayable ? (
|
||||
<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">{inProgress ? t("plex.resume") : t("plex.play")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
|
||||
)}
|
||||
<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 && (
|
||||
|
|
@ -467,13 +452,25 @@ function PlexPosterCard({
|
|||
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 className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||
>
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -481,14 +478,12 @@ function PlexPosterCard({
|
|||
|
||||
function PlexInfoView({
|
||||
id,
|
||||
library,
|
||||
onBack,
|
||||
onPlay,
|
||||
onPlayItem,
|
||||
onFilter,
|
||||
}: {
|
||||
id: string;
|
||||
library: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string) => void;
|
||||
|
|
@ -514,7 +509,7 @@ function PlexInfoView({
|
|||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={library}
|
||||
library={q.data.library ?? undefined}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
|
|
@ -539,6 +534,16 @@ function epLabel(ep?: PlexCard | null): string | undefined {
|
|||
return ep.title;
|
||||
}
|
||||
|
||||
// A metadata value that filters the unified grid when clicked (if onClick is wired), else plain text.
|
||||
function Fil({ onClick, className, children }: { onClick?: () => void; className?: string; children: ReactNode }) {
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button onClick={onClick} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -613,25 +618,43 @@ function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }
|
|||
);
|
||||
}
|
||||
|
||||
function CastStrip({ cast }: { cast: PlexCastMember[] }) {
|
||||
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<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) => (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
<div className="mx-auto h-20 w-20 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{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>
|
||||
)}
|
||||
{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 className="mt-1.5 text-xs font-medium line-clamp-2 leading-tight">{c.name}</div>
|
||||
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -666,7 +689,17 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
|
|||
);
|
||||
}
|
||||
|
||||
function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void; onAdd: () => void }) {
|
||||
function EpisodeCard({
|
||||
ep,
|
||||
onPlay,
|
||||
onAdd,
|
||||
withShowTitle,
|
||||
}: {
|
||||
ep: PlexCard;
|
||||
onPlay: () => void;
|
||||
onAdd?: () => void;
|
||||
withShowTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const pct =
|
||||
|
|
@ -701,20 +734,30 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
|||
</button>
|
||||
<div className="mt-1.5 flex items-start gap-1.5">
|
||||
<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">
|
||||
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
||||
{!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>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
{onAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -722,18 +765,18 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
|
|||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
library,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onFilter,
|
||||
}: {
|
||||
showId: string;
|
||||
library: 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();
|
||||
|
|
@ -745,6 +788,7 @@ function PlexShowView({
|
|||
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";
|
||||
async function markAll(w: boolean) {
|
||||
|
|
@ -755,7 +799,7 @@ function PlexShowView({
|
|||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -776,22 +820,37 @@ function PlexShowView({
|
|||
<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 && <span>{show.year}</span>}
|
||||
{show.content_rating && <span>· {show.content_rating}</span>}
|
||||
{show.year != null && (
|
||||
<Fil onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
|
||||
{show.year}
|
||||
</Fil>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<Fil onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
|
||||
· {show.content_rating}
|
||||
</Fil>
|
||||
)}
|
||||
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
|
||||
{show.imdb_rating != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{show.genres.map((g) => (
|
||||
<span key={g} className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted">
|
||||
<Fil
|
||||
key={g}
|
||||
className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
</Fil>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -824,7 +883,7 @@ function PlexShowView({
|
|||
label={t("plex.playlist.addShow")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && library && (
|
||||
{isAdmin && showLib && (
|
||||
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -839,16 +898,16 @@ function PlexShowView({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{show.cast.length > 0 && <CastStrip cast={show.cast} />}
|
||||
{show.cast.length > 0 && <CastStrip cast={show.cast} onFilter={onFilter} />}
|
||||
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && (
|
||||
{collOpen && show && showLib && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: show.id, title: show.title }}
|
||||
library={library}
|
||||
library={showLib}
|
||||
memberOf={show.collection_keys}
|
||||
onClose={() => setCollOpen(false)}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
|
||||
|
|
@ -887,7 +946,7 @@ function PlexSeasonView({
|
|||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export default function PlexInfo({
|
|||
const setState = async (status: "new" | "watched") => {
|
||||
await api.plexSetState(detail.id, status).catch(() => {});
|
||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
onStateChange?.();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
||||
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
||||
|
|
@ -12,8 +11,8 @@ import { useDebounced } from "../lib/useDebounced";
|
|||
// backend so the sidebar only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
library: string;
|
||||
setLibrary: (v: string) => void;
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
setScope: (v: string) => void;
|
||||
show: string;
|
||||
setShow: (v: string) => void;
|
||||
sort: string;
|
||||
|
|
@ -39,8 +38,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
|||
];
|
||||
|
||||
export default function PlexSidebar({
|
||||
library,
|
||||
setLibrary,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
setShow,
|
||||
sort,
|
||||
|
|
@ -52,7 +51,6 @@ export default function PlexSidebar({
|
|||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const qc = useQueryClient();
|
||||
|
|
@ -64,33 +62,17 @@ export default function PlexSidebar({
|
|||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
|
||||
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both).
|
||||
const facetsQ = useQuery({
|
||||
queryKey: ["plex-facets", library],
|
||||
queryFn: () => api.plexFacets(library),
|
||||
enabled: !!library, // facets now come for movie AND show libraries (0052)
|
||||
queryKey: ["plex-facets", scope],
|
||||
queryFn: () => api.plexFacets(scope),
|
||||
enabled: !!scope,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
// Collections picker (searchable; the list is only fetched when none is active).
|
||||
const [collSearch, setCollSearch] = useState("");
|
||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||
const collectionsQ = useQuery({
|
||||
queryKey: ["plex-collections", library, collSearchDeb],
|
||||
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
||||
enabled: !!library && !filters.collection, // collections apply to shows too
|
||||
});
|
||||
const collections = collectionsQ.data?.collections ?? [];
|
||||
|
||||
// Default to the first library once loaded (or if the stored one vanished).
|
||||
useEffect(() => {
|
||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||
}, [libs, library, setLibrary]);
|
||||
|
||||
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
|
||||
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
||||
// collection"; there's no per-library picker in the unified cross-library scope.)
|
||||
const fCount = plexFilterCount(filters);
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
|
|
@ -103,7 +85,8 @@ export default function PlexSidebar({
|
|||
setSort("added");
|
||||
};
|
||||
|
||||
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
||||
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
||||
const sorts = scope === "movie" ? MOVIE_SORTS : SHOW_SORTS;
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
|
|
@ -137,20 +120,19 @@ export default function PlexSidebar({
|
|||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||
{/* Library scope */}
|
||||
<Section label={t("plex.filter.library")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{libs.map((l) => (
|
||||
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||
<Section label={t("plex.filter.scope")}>
|
||||
<div className="flex gap-1">
|
||||
{(["both", "movie", "show"] as const).map((s) => (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => setLibrary(l.key)}
|
||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
||||
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
key={s}
|
||||
onClick={() => setScope(s)}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
||||
<span className="flex-1 text-left truncate">{l.title}</span>
|
||||
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
||||
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -266,42 +248,18 @@ export default function PlexSidebar({
|
|||
</Section>
|
||||
)}
|
||||
|
||||
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
||||
<Section label={t("plex.filter.collection")}>
|
||||
{filters.collection ? (
|
||||
{/* Collection — active-collection chip only (set from an item's info page "Browse
|
||||
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */}
|
||||
{filters.collection && (
|
||||
<Section label={t("plex.filter.collection")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => setCollSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* IMDb rating min */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
|
|
@ -369,7 +327,7 @@ export default function PlexSidebar({
|
|||
</Section>
|
||||
|
||||
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||
{isMovieLib && (
|
||||
{scope === "movie" && (
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
<ChipRow>
|
||||
<Chip
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue