import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
// 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: "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;
library: string;
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,
library,
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]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-browse", library, dq, sort, show, filters],
enabled: !!library && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexBrowse({
library,
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;
// Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
// adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else).
const peopleQ = useQuery({
queryKey: ["plex-people", library, dq],
queryFn: () => api.plexPeople(library, dq),
enabled: !!library && dq.length >= 2 && sub.view.kind === "grid",
});
const people = peopleQ.data?.people ?? [];
function addPerson(p: PlexPerson) {
const key = p.kind === "director" ? "directors" : "actors";
const cur = filters[key];
if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] });
onClearSearch(); // switch from the name search to the person filter (clears the search box)
}
// 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) {
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
qc.invalidateQueries({ queryKey: ["plex-browse"] });
}
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={(patch) => {
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
// info page instead of leaving the Plex module.
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);
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
sub.open({ kind: "grid" });
}}
/>
);
}
if (sub.view.kind === "show") {
return (
sub.open({ kind: "player", id: ep.id })}
/>
);
}
return (
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
{/* Virtual person cards for a name search — click to add them to the filter. */}
{people.length > 0 && (