Files
siftlode/frontend/src/components/plexViews.tsx
T
peter b83248c510 fix(e5): Plex detail/UI polish from review (backdrop reset, layout, combobox, labels)
User-reported issues after the E5 build:

- useCrossfadeBackdrop: the static branch (one image / crossfade off /
  reduced motion) painted #root but returned NO cleanup, so the backdrop
  persisted after leaving the page (e.g. switching modules). Every painted
  branch now returns clear() so unmount always resets the default backdrop.
- scheduler: give the tmdb_enrich job a proper label + description (was the
  raw "tmdb_enrich" id) in EN/HU.
- CastStrip: cap crew names to ONE line (truncate) so the row of circular
  photos sits on one baseline instead of going wavy on 2-line names.
- PlexInfo: lay Details / Keywords / Crew out as side-by-side boxes
  (items-start grid) instead of full-width stacked panels with dead space;
  narrower Crew grid for the boxed width; each panel independently hideable
  from the customize menu (new Details/Keywords/Crew toggles). Tighter
  vertical spacing.
- MultiSelectChips: reshape the trigger from a "+Add" button into an
  Overseerr-style combi input — removable chips inside a typeable field that
  filters the checklist, opening on focus (backspace removes the last chip).
- Discover / person rails: reduce the vertical gap between rails.
2026-08-21 03:16:40 +02:00

700 lines
26 KiB
TypeScript

import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
import { plexLang } from "../i18n";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import { toggleWatchState } from "../lib/plexWatch";
import {
ActionBtn,
BackBtn,
DetailCustomizeMenu,
DetailHero,
epLabel,
Filterable,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
WatchedToggle,
} from "../lib/plexDetailUi";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { Carousel } from "./Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./QueryState";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
// detail-page UI from lib/plexDetailUi.
// Lazy: the rich movie info panel loads only when an info page is opened.
const PlexInfo = lazy(() => import("./PlexInfo"));
export function PlexInfoView({
id,
onBack,
onPlay,
onPlayItem,
onFilter,
onOpenPerson,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<LoadingState label={t("plex.loading")} />
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo
detail={q.data}
variant="page"
library={q.data.library ?? undefined}
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
</Suspense>
)}
</div>
);
}
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
export function PlexPersonView({
name,
onBack,
onOpenCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
const pd = q.data;
useCrossfadeBackdrop(
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
artBg,
crossfade,
);
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
old
? {
...old,
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
known_for: old.known_for.map((k) =>
k.id === card.id ? { ...k, status: next } : k,
),
}
: old,
),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
))}
</div>
</section>
) : null;
const d = q.data;
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !d ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<div className="p-4">
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="text-xl font-semibold">{d.name}</h1>
{d.known_for_department && (
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
)}
<p className="mt-1 mb-2 text-xs text-muted">
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
</p>
<div className="mb-2 space-y-0.5 text-xs text-muted">
{(d.birthday || d.place_of_birth) && (
<p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
</p>
)}
{d.deathday && (
<p>
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
</p>
)}
{d.also_known_as.length > 0 && (
<p className="truncate">
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
{d.also_known_as.slice(0, 4).join(", ")}
</p>
)}
</div>
<button
onClick={() => onFilter({ actors: [d.name] })}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
{t("plex.person.filterBy", { name: d.name })}
</button>
</div>
</div>
{d.biography && (
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
{d.biography}
</p>
)}
</section>
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
</div>
))}
</Carousel>
</section>
)}
{empty ? (
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{section("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)}
</>
)}
</div>
)}
</div>
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
// Add the whole show to a collection.
export function PlexShowView({
showId,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
onFilter,
onOpenPerson,
}: {
showId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const lang = plexLang();
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const show = d?.show;
const showLib = show?.library ?? undefined;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
const {
artBg,
showCast,
showRelated,
crossfade,
toggleArtBg,
toggleCast,
toggleRelated,
toggleCrossfade,
} = useDetailPrefs();
useCrossfadeBackdrop(
[
show?.art,
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(d?.related ?? []).map((r) => r.art),
],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markSeason(seasonRk: string, w: boolean) {
try {
await api.plexSeasonState(seasonRk, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
{/* Hero */}
<DetailHero
thumb={show.thumb}
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!show.resume}
showPlayOverlay={!!show.resume}
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
/>
}
>
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
· {show.content_rating}
</Filterable>
)}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{(() => {
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
// rating instead of dropping the badge entirely.
const badge = pickRatingBadge(show.imdb_rating, show.rating);
if (!badge) return null;
const { score } = badge;
return (
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)}
</button>
);
})()}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<Filterable
key={g}
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markShowUnwatched")}
unwatchedLabel={t("plex.series.markShowWatched")}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
</DetailHero>
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => (
<SeasonCard
key={se.id}
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
onAdd={
se.episodes.length > 0
? () =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`,
})
: undefined
}
/>
))}
</div>
</section>
{show.cast.length > 0 && showCast && (
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
)}
{d.related.length > 0 && showRelated && (
<RelatedStrip related={d.related} onOpen={onOpenShow} />
)}
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && showLib && (
<PlexCollectionEditor
item={{ id: show.id, title: show.title }}
library={showLib}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
/>
)}
</div>
);
}
export function PlexSeasonView({
showId,
seasonId,
onBack,
onPlay,
}: {
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [busy, setBusy] = useState(false);
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
// straight into the next season's first — the same cross-season order the show page already uses.
// seasonKeys stays only for the season-scoped "add to playlist" action.
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
const watched = se?.status === "watched";
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
useCrossfadeBackdrop(
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markEpisode(ep: PlexCard) {
try {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={se.thumb}
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!se.resume}
showPlayOverlay={!!se.resume}
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
posterSizeClass="w-28 sm:w-40"
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={false}
/>
}
>
<button
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
{d.show.title}
</button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markSeasonUnwatched")}
unwatchedLabel={t("plex.series.markSeasonWatched")}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
</DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
<EpisodeCard
key={ep.id}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
))}
</div>
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
</div>
);
}