import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } 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 { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; 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; onClearSearch: () => void; 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; function dur(n?: number | null): string { if (!n) return ""; const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; } export default function PlexBrowse({ q, onClearSearch, scope, setScope, show, sort, filters, setFilters, openPlaylist, onPlaylistOpened, }: Props) { const { t } = useTranslation(); const qc = useQueryClient(); const dq = useDebounced(q.trim(), 350); const sub = useHistorySubview({ 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
(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. const sentinel = useRef(null); const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ; useEffect(() => { const el = sentinel.current; if (!el) return; const io = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { rootMargin: "600px" }, ); io.observe(el); return () => io.disconnect(); }, [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 }); } async function toggleWatched(card: PlexCard) { 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>({ 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) { const merged = { ...filters } as unknown as Record; 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 ( }> ); } if (sub.view.kind === "playlist") { const pid = sub.view.id; return ( {t("plex.loading")}

}> sub.open({ kind: "player", id: itemRk, queue })} />
); } if (sub.view.kind === "info") { const infoId = sub.view.id; return ( 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 ( 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 ( sub.open({ kind: "player", id: epRk, queue })} /> ); } return (

{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}

{browseQ.isLoading ? (

{t("plex.loading")}

) : 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".
{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}
) : (

{dq ? t("plex.noMatches") : t("plex.empty")}

) ) : ( <> {/* Titles grid — movies + shows mixed (visually tagged). */} {items.length > 0 && ( <> {episodes.length > 0 && (

{t("plex.unified.titles")}

)}
{items.map((c) => ( onCard(c)} onInfo={() => onInfo(c)} onToggleWatched={toggleWatched} /> ))}
)} {/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */} {episodes.length > 0 && (

{t("plex.unified.episodes")}

{episodes.map((ep) => ( onCard(ep)} /> ))}
)} )}
{isFetchingNextPage &&

{t("plex.loading")}

}
); } const PLAYABLE_TINT: Record = { 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 (
{ 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" > {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
{card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
{/* Quick watched toggle (movies/episodes), on hover. */} {isPlayable && ( )} {/* Media info page (movies/episodes). */} {isPlayable && ( )} {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( {t("plex.watched")} )} {card.type === "show" && card.season_count != null && ( {t("plex.seasons", { count: card.season_count })} )} {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && ( {t("plex.inProgress")} )} {card.playable && ( )} {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} {card.type === "show" ? : } {pct > 0 && (
)}
{card.title}
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
); } function PlexInfoView({ id, onBack, onPlay, onPlayItem, onFilter, }: { id: string; onBack: () => void; onPlay: () => void; onPlayItem: (id: string) => void; onFilter: (patch: Partial) => void; }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); return (
{q.isLoading || !q.data ? (

{t("plex.loading")}

) : ( {t("plex.loading")}

}> q.refetch()} />
)}
); } // --- 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; } // 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 {children}; return ( ); } function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( ); } function ActionBtn({ onClick, icon: Icon, label, sub, primary, disabled, }: { onClick: () => void; icon: LucideIcon; label: string; sub?: string; primary?: boolean; disabled?: boolean; }) { return ( ); } function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }) { const { t } = useTranslation(); return ( ); } function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void }) { const { t } = useTranslation(); return (

{t("plex.info.cast")}

{cast.map((c, i) => { const inner = ( <>
{c.thumb ? ( ) : (
{c.name.charAt(0)}
)}
{c.name}
{c.role &&
{c.role}
} ); return onFilter ? ( ) : (
{inner}
); })}
); } function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) { const { t } = useTranslation(); return (

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

{related.map((r) => ( ))}
); } 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 = inProgress && ep.duration_seconds ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) : 0; return (
{withShowTitle && ep.show_title && (
{ep.show_title} {ep.season_number != null && ep.episode_number != null ? ` · S${ep.season_number}·E${ep.episode_number}` : ""}
)}
{!withShowTitle && {ep.episode_number}.} {ep.title}
{dur(ep.duration_seconds)}
{onAdd && ( )}
); } 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) => 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(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"; 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"] }); } return (
{q.isLoading || !d || !show ? (

{t("plex.loading")}

) : ( <> {/* Hero */}

{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 })}} {show.imdb_rating != null && ( )}
{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)} disabled={busy} icon={watched ? CheckCheck : Check} label={watched ? t("plex.series.markShowUnwatched") : 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 */}

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

{d.seasons.map((se) => ( onOpenSeason(se.id)} /> ))}
{show.cast.length > 0 && } {d.related.length > 0 && } )} {addTarget && setAddTarget(null)} />} {collOpen && show && showLib && ( setCollOpen(false)} onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })} /> )}
); } 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(null); const [busy, setBusy] = useState(false); const seasonKeys = se?.episodes.map((e) => e.id) ?? []; const watched = se?.status === "watched"; 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"] }); } return (
{q.isLoading || !d || !se ? (

{t("plex.loading")}

) : ( <>

{d.show.title}

{se.title}

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

{se.resume && ( 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" && ( onPlay(se.first!.id, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> )} markAll(!watched)} disabled={busy} icon={watched ? CheckCheck : Check} label={watched ? t("plex.series.markSeasonUnwatched") : 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, seasonKeys)} onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} /> ))}
)} {addTarget && setAddTarget(null)} />}
); }