import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData, } from "@tanstack/react-query"; import { User, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { healPlexField } from "../lib/plexSearch"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import type { SortState } from "../lib/columnSort"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexCard, type PlexFilters, type PlexUnifiedResult, } from "../lib/api"; import { plexLang } from "../i18n"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { toggleWatchState } from "../lib/plexWatch"; import SortControl from "./SortControl"; import FilterSelect from "./FilterSelect"; import PlexDiscover from "./PlexDiscover"; import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import SelectedViewChip from "./SelectedViewChip"; import ItemCount from "./ItemCount"; import { StateMessage } from "./QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; import { usePublishPlexView } from "./PlexViewBridge"; import { EpisodeCard, PlexPosterCard } from "./plexCards"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); const PlexCollectionView = lazy(() => import("./PlexCollectionView")); // 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. Rendered by App for page==="plex" (its own // nav module). The cards/strips live in plexCards; the sub-view branches in plexViews. const PAGE = 40; // D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most // recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference). const LASTWATCHED_SORT: NonNullable = Object.freeze({ key: "lastwatched", dir: "desc" as const, }); // Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am // I looking at" control now, like the feed's Watch-state pill, not a rail widget). const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the // search term (q) comes from the shared header box; the rail hands off a playlist to open via the // shared `playlistOpen` slot, which we consume into a history subview then clear. const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex(); const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); const dq = useDebounced(q.trim(), 350); // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch. const lang = plexLang(); // The language the grid query is allowed to run at — lags `lang` by one render on a switch so the // genre-clear effect (below) can settle first (see the effect for why). const [committedLang, setCommittedLang] = useState(lang); // Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per // narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort. const plexChips = usePlexActiveFilters(); const clearAllFilters = usePlexClearFilters(); // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. const sortState = healPlexSort(sort, scope); const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); // Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search // or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns. const sub = useHistorySubview({ kind: "discover" }, PLEX_SUB_URL); // Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via // the bridge; clear it on unmount so the toggle disappears when you leave the plex page. Keyed on the // view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing). const publishView = usePublishPlexView(); useLayoutEffect(() => { publishView({ kind: sub.view.kind, open: sub.open }); return () => publishView(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sub.view.kind]); // 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 }); setPlaylistOpen(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPlaylist]); // The header search typeahead picked a title → open it the same way a card click would (a movie's // info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open. useEffect(() => { if (jumpTo != null) { if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top sub.open( jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id }, ); setJumpTo(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [jumpTo]); // Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on // the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back // to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip // trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so // adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid. const browseIntent = !!dq; const prevIntent = useRef(browseIntent); useEffect(() => { // A search targets titles, so jump to the title grid from any non-title landing (Discover or the // playlists/collections browse surfaces) when a search first appears. const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind); if (onListing && browseIntent && !prevIntent.current) { sub.open({ kind: "grid" }); } prevIntent.current = browseIntent; }, [browseIntent, sub]); // 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 ( [ "grid", "playlists", "collections", "show", "season", "info", "person", "playlist", "collection", ].includes(sub.view.kind) ) { e.preventDefault(); sub.back(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [sub]); // D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently // watched first); leaving restores the sort that was active before entering. Only the ENTER edge // forces the sort, so a manual sort change while in the in-progress view still wins — it's just not // carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not // the scope-healed one, so the user's real preference round-trips. const prevShow = useRef(show); const preInProgressSort = useRef(null); useEffect(() => { const was = prevShow.current; if (was !== "in_progress" && show === "in_progress") { preInProgressSort.current = sort; setSort(LASTWATCHED_SORT); } else if (was === "in_progress" && show !== "in_progress") { if (preInProgressSort.current) setSort(preInProgressSort.current); preInProgressSort.current = null; } prevShow.current = show; }, [show, sort, setSort]); const browseQ = useInfiniteQuery({ queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang), // Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect // settles), not a throwaway one with stale-language genres followed by a second. enabled: !!scope && sub.view.kind === "grid" && committedLang === lang, initialPageParam: 0, queryFn: ({ pageParam }) => api.plexLibrary({ scope, q: dq || undefined, field: fieldState, sort: sortState, show, lang, filters, offset: pageParam as number, limit: PAGE, }), getNextPageParam: (last) => { const seen = last.offset + last.items.length; return seen < last.total ? seen : undefined; }, }); // Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a // selection made in one language matches nothing against the other's column and would silently empty // the grid. On a language flip, drop the genre selection — and gate the grid query on the language // being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and // then a second after the clear; the query waits for both to settle into a single fetch. useEffect(() => { if (committedLang !== lang) { setCommittedLang(lang); if (filters.genres.length) setFilters({ ...filters, genres: [] }); } }, [lang, committedLang, filters, setFilters]); const items = browseQ.data?.pages.flatMap((p) => p.items) ?? []; const total = browseQ.data?.pages[0]?.total ?? 0; // Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the // grid so you SEE who you're exploring, not just chips in the rail. const peopleCardsQ = useQuery({ queryKey: qk.plexPeopleCards(scope, filters.actors), queryFn: () => api.plexPeopleCards(filters.actors, scope), enabled: sub.view.kind === "grid" && filters.actors.length > 0, staleTime: 60_000, }); const personCards = peopleCardsQ.data?.people ?? []; const removeActor = (name: string) => setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) }); // 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. The sentinel lives // in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on // Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every // mount/unmount, so the observer always watches the live node; a plain ref would keep observing the // detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages). const [sentinelEl, setSentinelEl] = useState(null); const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ; useEffect(() => { if (!sentinelEl) return; const io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { rootMargin: "600px" }, ); io.observe(sentinelEl); return () => io.disconnect(); }, [sentinelEl, 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 }); } // Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip). // Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where // it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a // later info page would wrongly restore this stale offset. function onOpenPerson(name: string) { if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0; sub.open({ kind: "person", name }); } // Optimistically flip a card's status in every cached library page — both the title grid (`items`) // and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe. function optimisticStatus(id: string, next: string) { qc.setQueriesData>({ queryKey: qk.plexLibrary() }, (old) => old ? { ...old, pages: old.pages.map((pg) => ({ ...pg, items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)), episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)), })), } : old, ); } const toggleWatched = (card: PlexCard) => toggleWatchState(card, t, { optimistic: (next) => optimisticStatus(card.id, next), resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), }); // 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; const isPlainObject = (x: unknown): x is Record => typeof x === "object" && x !== null && !Array.isArray(x); 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 if (isPlainObject(v) && isPlainObject(cur)) { // Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles. merged[k] = { ...cur, ...v }; } 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") { // The fallback is portaled for the same reason the player itself is: it is a fixed // full-viewport overlay, and this tree renders inside the page scroller. return ( , document.body)} > ); } 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 === "collection") { const cid = sub.view.id; return ( {t("plex.loading")}

}> sub.open({ kind: "player", id: rk, queue })} onOpenCard={onCard} onInfoCard={onInfo} />
); } 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} onOpenPerson={onOpenPerson} /> ); } if (sub.view.kind === "person") { const personName = sub.view.name; return ( ); } 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} onOpenPerson={onOpenPerson} /> ); } if (sub.view.kind === "season") { const { showId, seasonId } = sub.view; return ( sub.open({ kind: "player", id: epRk, queue })} /> ); } // Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the // applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as // the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill). const sortView = ( setSort(next ?? PLEX_SORT_FALLBACK)} label={t("plex.filter.sort")} ascLabel={t("plex.filter.dir.asc")} descLabel={t("plex.filter.dir.desc")} /> ); if (sub.view.kind === "discover") { return (
applyFilter({ genres: [g] })} onSeeAllRecent={() => sub.open({ kind: "grid" })} />
); } // The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A // playlist tile opens that playlist; a collection tile filters the title grid by that collection. if (sub.view.kind === "playlists") { return (
sub.open({ kind: "playlist", id })} />
); } if (sub.view.kind === "collections") { return (
sub.open({ kind: "collection", id })} />
); } return (
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */}
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail), the same idiom as the feed's Watch-state FilterSelect. */} ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))} selected={[show]} onChange={(next) => setShow(next[0] ?? "all")} />
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
{sortView}
{personCards.length > 0 && (
{personCards.map((p) => (
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
))}
)} {browseQ.isError && !browseQ.data ? ( browseQ.refetch()}> {t("common.loadError")} ) : 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)} onToggleWatched={() => toggleWatched(ep)} /> ))}
)} )}
{isFetchingNextPage && (

{t("plex.loading")}

)}
); }