import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, Layers, ListPlus, Play, RotateCcw, Star, Tv2, Undo2, User, } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCard, type PlexFilters, type PlexPersonDetail, type PlexSeasonDetail, } from "../lib/api"; import { plexLang } from "../i18n"; import { pickRatingBadge } from "../lib/plexRating"; import { notifyActionError } from "../lib/actionErrors"; import { toggleWatchState } from "../lib/plexWatch"; import { showEpisodeKeys } from "../lib/plexQueue"; import { ActionBtn, BackBtn, DetailCustomizeMenu, DetailHero, Filterable, WatchedToggle, } from "./plexDetailUi"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { epLabel, seasonTitle, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs, useDetailAside, } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { useCopyItems } from "./copyItems"; import { ContextMenu } from "./ui/ContextMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { Button } from "./ui/Button"; import { ClampedText } from "./ui/ClampedText"; import { Carousel } from "./ui/Carousel"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; import { Chip } from "./ui/Chip"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; import { vocab } from "../lib/vocab"; import { formatDate, formatDecimal } from "../lib/format"; // 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 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, fromStart?: boolean) => void; onFilter: (patch: Partial) => void; onOpenPerson: (name: string) => void; }) { const { t, i18n } = useTranslation(); const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); return (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !q.data ? ( ) : ( {t("plex.loading")}

}>
)}
); } // How often, and for how long, a person page asks again for a bio that is on its way. The // background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one. const BIO_POLL_MS = 1500; // From this many "Known for" titles the rail gets half the hero. Fewer (about one person in five) sit // right beside the text, at their posters' width, and what is left over is the panel's end — not a hole // between the text and a lone poster at the far edge (owner pick "b"). const KNOWN_FOR_HALF = 4; const BIO_WAIT_MS = 20_000; // --- 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, onRestartCard, onInfoCard, onFilter, }: { name: string; onBack: () => void; onOpenCard: (card: PlexCard) => void; onRestartCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; onFilter: (patch: Partial) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); // ONE menu for this whole page — the "known for" rail and the two filmography grids are three // lists on one screen, and the popover anchors where it opened, so a second controller would only // mean a second closed popover to keep shut (see ui/ContextMenu). const menu = useContextMenu(); const lang = plexLang(i18n.language); const personKey = qk.plexPerson(name, lang); // A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens; // the answer says so (`enriching`) and the page asks again until it lands — for a while, not // forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask // leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at // once (the next open tries again). The page is keyed on the person, so the wait starts per person; // a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing. const [waitOver, setWaitOver] = useState(false); const [waitRound, setWaitRound] = useState(0); useEffect(() => { const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS); return () => clearTimeout(timer); }, [waitRound]); const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang), refetchInterval: (query) => query.state.data?.enriching && !waitOver && query.state.status !== "error" ? BIO_POLL_MS : false, }); const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError; // 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(personKey, (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 actions = usePlexCardActions( { onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle }, menu.open, ); const cardMenu = (c: PlexCard) => ({ onRestart: () => onRestartCard(c), onAdd: actions.add, menuProps: menu.triggerProps, menuButtonProps: menu.buttonProps, menuOpen: menu.open && menu.target === c, }); // Not `section`: a local named like the
it renders confused the React Compiler's scope // analysis ("[hoisting] expected identifier to be defined") and it skipped the component. const renderSection = (titleKey: string, cards: PlexCard[]) => cards.length > 0 ? (

{t(titleKey, { count: cards.length })}

{cards.map((c, i) => ( onOpenCard(c)} onToggleWatched={toggle} {...cardMenu(c)} /> ))}
) : null; const d = q.data; const empty = !!d && d.movies.length === 0 && d.shows.length === 0; return (
{q.isError && !d ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !d ? ( ) : (
{/* Hero (owner pick "A"): the person — headshot, facts, a clamped bio — on the left, what they are known for on the right, so the bio doesn't sit in a full-width panel with an empty half beside it. One column where the panel is narrow. */}
= KNOWN_FOR_HALF ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" : d.known_for.length > 0 ? "grid gap-6 @3xl:grid-cols-[auto_auto] @3xl:justify-start" : undefined } > {/* The person's column is at most the bio's reading measure (text-sm, as the bio is), so a short rail can sit right beside it. */}
{d.photo ? ( ) : (
)}
{d.name} {d.known_for_department && (

{vocab(t, "department", d.known_for_department)}

)}

{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}

{(d.birthday || d.place_of_birth) && (

{t("plex.person.born")}:{" "} {[formatDate(d.birthday, lang), d.place_of_birth] .filter(Boolean) .join(" · ")}

)} {d.deathday && (

{t("plex.person.died")}:{" "} {formatDate(d.deathday, lang)}

)} {d.also_known_as.length > 0 && (

{t("plex.person.aka")}:{" "} {d.also_known_as.slice(0, 4).join(", ")}

)}
onFilter({ actors: [d.name] })} className="font-medium"> {t("plex.person.filterBy", { name: d.name })}
{d.biography ? ( ) : bioOnItsWay ? ( ) : ( // An ask that failed while the bio was on its way is said, not left looking like a // person with no bio; the retry asks (and waits) again. d.enriching && q.isError && ( { setWaitOver(false); setWaitRound((n) => n + 1); void q.refetch(); }} > {t("plex.person.bioFailed")} ) )}
{/* "Known for" — the person's most-popular titles that are in our library. */} {d.known_for.length > 0 && (
{d.known_for.map((c, i) => (
onOpenCard(c)} onToggleWatched={toggle} {...cardMenu(c)} />
))}
)}
{empty ? (

{t("plex.person.empty")}

) : ( <> {renderSection("plex.person.movies", d.movies)} {renderSection("plex.person.shows", d.shows)} )}
)}
); } // --- 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[], fromStart?: boolean) => void; onOpenSeason: (seasonId: string) => void; onOpenShow: (id: string) => void; onFilter?: (patch: Partial) => void; onOpenPerson?: (name: string) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang), }); const d = q.data; const [addTarget, setAddTarget] = useState(null); const [collOpen, setCollOpen] = useState(false); const [busy, setBusy] = useState(false); const relatedMenu = useContextMenu(); const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open); const seasonMenu = useContextMenu(); const copy = useCopyItems(); const show = d?.show; const showLib = show?.library ?? undefined; const allKeys = showEpisodeKeys(d); const watched = show?.status === "watched"; const { artBg, showCast, showRelated, crossfade, toggleArtBg, toggleCast, toggleRelated, toggleCrossfade, } = useDetailPrefs(); // The facts / keywords / crew: TMDB enriches shows too, and the page showed none of it. const aside = useDetailAside(show ?? {}); 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")); } 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() }); } // A season's add target (none for an empty season, which has nothing to add). const addSeason = (se: PlexSeasonDetail) => show && se.episodes.length > 0 ? () => setAddTarget({ kind: "group", ratingKeys: se.episodes.map((e) => e.id), title: `${show.title} — ${seasonTitle(se, t)}`, }) : undefined; // A season's one action list: the rows its card shows on hover, plus copy. const seasonItems = (se: PlexSeasonDetail) => { const seWatched = se.status === "watched"; const add = addSeason(se); return [ { id: "open", label: t("plex.series.openSeason"), icon: , onSelect: () => onOpenSeason(se.id), }, { id: "watched", label: seWatched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched"), icon: seWatched ? : , separatorBefore: true, onSelect: () => void markSeason(se.id, !seWatched), }, ...(add ? [ { id: "playlist", label: t("plex.playlist.addSeason"), icon: , onSelect: add, }, ] : []), ...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })), ]; }; return (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !d || !show ? ( ) : ( <> {/* Hero */} show.resume && onPlay(show.resume.id, allKeys)} customizeMenu={ 0} showRelated={showRelated} onToggleRelated={toggleRelated} hasRelated={d.related.length > 0} extra={} /> } aside={ aside.column && ( ) } > {show.title} {/* The same meta row as the media-info page: filtering values are chips, plain facts flat text, spaced by the gap alone (a '·' between chips reads as noise). */}
{show.year != null && ( onFilter({ yearMin: show.year, yearMax: show.year }) : undefined } > {show.year} )} {show.content_rating && ( onFilter({ contentRatings: [show.content_rating!] }) : undefined } > {vocab(t, "certificate", show.content_rating)} )} {show.season_count != null && ( {t("plex.seasons", { count: show.season_count })} )} {(() => { // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show // with no imdb:// entry (imdb_rating NULL since the 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 ( onFilter({ ratingMin: Math.floor(score) }) : undefined } title={ onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined } className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`} > {/* the rating gold — its own token, not --warn: a score is not a warning */} {formatDecimal(score, lang)} ); })()}
{show.genres.length > 0 && (
{show.genres.map((g) => ( onFilter({ genres: [g] }) : undefined} > {g} ))}
)} {show.summary && ( )}
{show.resume && ( 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" && ( onPlay(show.first!.id, allKeys, true)} icon={RotateCcw} label={t("plex.series.showFromStart")} sub={epLabel(show.first)} /> )} markAll(!watched)} watchedLabel={t("plex.series.markShowUnwatched")} unwatchedLabel={t("plex.series.markShowWatched")} /> {allKeys.length > 0 && ( setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) } icon={ListPlus} label={t("plex.playlist.addShow")} /> )} {isAdmin && showLib && ( setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} /> )}
{aside.shown.keywords && ( )}
{/* Seasons — in a glassy panel like the movie info-page strips. */}

{t("plex.series.seasons")}

{d.seasons.map((se, i) => ( onOpenSeason(se.id)} onToggleWatched={() => markSeason(se.id, se.status !== "watched")} onAdd={addSeason(se)} menuProps={seasonMenu.triggerProps} menuButtonProps={seasonMenu.buttonProps} menuOpen={seasonMenu.open && seasonMenu.target === se} /> ))}
{show.cast.length > 0 && showCast && ( )} {d.related.length > 0 && showRelated && ( onOpenShow(c.id)} menuProps={relatedMenu.triggerProps} menuButtonProps={relatedMenu.buttonProps} openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null} /> )} )} {addTarget && setAddTarget(null)} />} {collOpen && show && showLib && ( setCollOpen(false)} onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} /> )}
); } export function PlexSeasonView({ showId, seasonId, onBack, onPlay, }: { showId: string; seasonId: string; onBack: () => void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const lang = plexLang(i18n.language); 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 epMenu = useContextMenu(); const [addTarget, setAddTarget] = useState(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 = showEpisodeKeys(d); 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")); } setBusy(false); qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } async function markEpisode(ep: PlexCard) { const next = ep.status === "watched" ? "new" : "watched"; try { await api.plexSetState(ep.id, next); } catch (e) { notifyActionError(e, t("plex.watchedFailed")); } qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } // The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin). const episodes = usePlexCardActions( { onOpen: (ep) => onPlay(ep.id, allKeys), onRestart: (ep) => onPlay(ep.id, allKeys, true), onToggleWatched: (ep) => void markEpisode(ep), }, epMenu.open, ); return (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !d || !se ? ( ) : ( <> se.resume && onPlay(se.resume.id, allKeys)} posterSizeClass="w-28 sm:w-40" customizeMenu={ } > {seasonTitle(se, t)}

{t("plex.series.episodeCount", { count: se.episode_count })}

{se.resume && ( 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" && ( onPlay(se.first!.id, allKeys, true)} icon={RotateCcw} label={t("plex.series.seasonFromStart")} sub={epLabel(se.first)} /> )} markAll(!watched)} watchedLabel={t("plex.series.markSeasonUnwatched")} unwatchedLabel={t("plex.series.markSeasonWatched")} /> {seasonKeys.length > 0 && ( setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title} — ${seasonTitle(se, t)}`, }) } icon={ListPlus} label={t("plex.playlist.addSeason")} /> )}
{se.episodes.map((ep, i) => ( onPlay(ep.id, allKeys)} onRestart={() => onPlay(ep.id, allKeys, true)} onAdd={() => episodes.add(ep)} onToggleWatched={() => markEpisode(ep)} menuProps={epMenu.triggerProps} menuButtonProps={epMenu.buttonProps} menuOpen={epMenu.open && epMenu.target === ep} /> ))} )} {addTarget && setAddTarget(null)} />}
); }