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 { 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 SegmentedToggle from "./SegmentedToggle"; import PlexDiscover from "./PlexDiscover"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import ItemCount from "./ItemCount"; import { StateMessage } from "./QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; 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")); // 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; 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, 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); // 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 browse intent: when a header search or a filter FIRST becomes active while on // the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge, // so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a // Back to Discover with the search still in the box doesn't immediately re-flip. const browseIntent = !!dq || plexFilterCount(filters) > 0; const prevIntent = useRef(browseIntent); useEffect(() => { if (sub.view.kind === "discover" && 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", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { e.preventDefault(); sub.back(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [sub]); 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; 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") { // 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 === "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 })} /> ); } // Discover ⟷ Browse switch, shown on both surfaces so either can return to the other. const viewToggle = ( sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })} /> ); if (sub.view.kind === "discover") { return (
{viewToggle}
applyFilter({ genres: [g] })} onSeeAllRecent={() => sub.open({ kind: "grid" })} />
); } 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. */}
{viewToggle} {/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
setSort(next ?? PLEX_SORT_FALLBACK)} label={t("plex.filter.sort")} ascLabel={t("plex.filter.dir.asc")} descLabel={t("plex.filter.dir.desc")} />
{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")}

)}
); }