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 } 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")); type Sub = | { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string } | { kind: "info"; id: string }; // 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; library: string; show: string; sort: string; filters: PlexFilters; setFilters: (f: PlexFilters) => 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, library, show, sort, filters, setFilters }: Props) { const { t } = useTranslation(); const qc = useQueryClient(); const dq = useDebounced(q.trim(), 350); const sub = useHistorySubview({ kind: "grid" }); // 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); const scroller = () => document.querySelector("main"); useLayoutEffect(() => { if (sub.view.kind === "grid" && scrollRef.current) { const el = scroller(); if (el) el.scrollTop = scrollRef.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; // 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; 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 === "info") { const infoId = sub.view.id; return ( sub.open({ kind: "player", id: infoId })} onFilter={(patch) => { // Clicking a metadata chip sets that filter and returns to the (now filtered) grid. setFilters({ ...filters, ...patch }); sub.back(); }} /> ); } 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 })}

{browseQ.isLoading ? (

{t("plex.loading")}

) : items.length === 0 ? (

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

) : (
{items.map((c) => ( onCard(c)} onInfo={() => onInfo(c)} onToggleWatched={toggleWatched} /> ))}
)}
{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: what a click does (play / resume / open show). */}
{isPlayable ? (
{inProgress ? t("plex.resume") : t("plex.play")}
) : ( {t("plex.openShow")} )}
{/* 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 })} )} {card.playable && ( )} {pct > 0 && (
)}
{card.title}
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
); } function PlexInfoView({ id, onBack, onPlay, onFilter, }: { id: string; onBack: () => void; onPlay: () => 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")}

}>
)}
); } function PlexShowView({ showId, onBack, onPlay, }: { showId: string; onBack: () => void; onPlay: (c: PlexCard) => void; }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); const d = q.data; return (
{q.isLoading || !d ? (

{t("plex.loading")}

) : ( <>

{d.show.title}

{d.show.year &&

{d.show.year}

} {d.show.summary && (

{d.show.summary}

)}
{d.seasons.map((se) => (

{se.title}

{se.episodes.map((ep) => { const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; return ( ); })}
))} )}
); }