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) => 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 (
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading || !q.data ? ( ) : ( {t("plex.loading")}

}>
)}
); } // --- 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) => 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(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 ? (

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

{cards.map((c) => ( onOpenCard(c)} onInfo={() => onInfoCard(c)} onToggleWatched={toggle} /> ))}
) : 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: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
{d.photo ? ( ) : (
)}

{d.name}

{d.known_for_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")}:{" "} {[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}

)} {d.deathday && (

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

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

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

)}
{d.biography && (

{d.biography}

)}
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} {d.known_for.length > 0 && (
{d.known_for.map((c) => (
onOpenCard(c)} onInfo={() => onInfoCard(c)} onToggleWatched={toggle} />
))}
)} {empty ? (

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

) : ( <> {section("plex.person.movies", d.movies)} {section("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[]) => void; onOpenSeason: (seasonId: string) => void; onOpenShow: (id: string) => void; onFilter?: (patch: Partial) => 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(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 (
{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} /> } >

{show.title}

{show.year != null && ( onFilter({ yearMin: show.year, yearMax: show.year }) : undefined } > {show.year} )} {show.content_rating && ( onFilter({ contentRatings: [show.content_rating!] }) : undefined } > · {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 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 ( ); })()}
{show.genres.length > 0 && (
{show.genres.map((g) => ( onFilter({ genres: [g] }) : undefined} > {g} ))}
)} {show.summary && (

{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)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> )} 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")} /> )}
{/* Seasons — in a glassy panel like the movie info-page strips. */}

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

{d.seasons.map((se) => ( 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 } /> ))}
{show.cast.length > 0 && showCast && ( )} {d.related.length > 0 && showRelated && ( )} )} {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[]) => 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(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 (
{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={ } >

{se.title}

{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)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> )} markAll(!watched)} watchedLabel={t("plex.series.markSeasonUnwatched")} unwatchedLabel={t("plex.series.markSeasonWatched")} /> {seasonKeys.length > 0 && ( setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title} — ${se.title}`, }) } icon={ListPlus} label={t("plex.playlist.addSeason")} /> )}
{se.episodes.map((ep) => ( onPlay(ep.id, allKeys)} onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} onToggleWatched={() => markEpisode(ep)} /> ))}
)} {addTarget && setAddTarget(null)} />}
); }