Files
siftlode/frontend/src/components/PlexBrowse.tsx
T
peter 76358a0d77 refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.

- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
  a ref, so every component holding one was skipped (21 call sites, the engine and its surface
  among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
  else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
  useSyncExternalStore, which React asks on every render instead of a cached memo.

Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
  the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
  The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
  draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
  inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
  "one writer" rule; the writer comes first now and the effect goes through it.

The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
2026-10-03 23:18:26 +02:00

765 lines
34 KiB
TypeScript

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 { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage";
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 "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { menuGroups } from "./ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ui/ItemCount";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { StateMessage } from "./ui/QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./plexContext";
import { usePublishPlexView } from "./plexViewContext";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// 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<SortState> = 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;
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
const BACKABLE_VIEWS: readonly string[] = [
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
];
export default function PlexBrowse() {
const { t, i18n } = 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(i18n.language);
// 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();
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
const fold = useFilterFold();
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}`), lang);
// 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<Sub>({ 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. `sub.open`
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
const publishView = usePublishPlexView();
useLayoutEffect(() => {
publishView({ kind: sub.view.kind, open: sub.open });
return () => publishView(null);
}, [sub.view.kind, sub.open, publishView]);
// 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);
}
}, [openPlaylist, sub, setPlaylistOpen]);
// Like the grid's scroll memory (below), 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);
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
// rule the code holds rather than one spread over the call sites. Declared above the effects that
// use it: an effect closing over the ref before its declaration made the compiler skip PlexBrowse.
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// The header search typeahead picked a title → open it the same way a card click would (a movie's
// info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it
// doesn't re-open.
useEffect(() => {
if (jumpTo != null) {
if (jumpTo.type !== "show") rememberInfoScroll(0); // a jumped-to info page starts at the top
sub.open(
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id, fromStart: jumpTo.fromStart }
: { kind: "info", id: jumpTo.id },
// A cross-module hand-off continues the navigation that brought us here rather than adding
// to it. It also makes this effect idempotent, which matters more than it looks: under
// StrictMode the effect body runs twice before `setJumpTo(null)` re-renders, and two pushes
// of the SAME sub-view left a duplicate entry whose Back visibly did nothing.
{ replace: jumpTo.replace },
);
setJumpTo(null);
}
}, [jumpTo, sub, setJumpTo]);
// 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 <main> (the page's overflow-y-auto).
const scrollRef = useRef(0);
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const epMenu = useContextMenu<PlexCard>();
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 chord and the typing guard come from the shared keymap.
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
// sheet can never promise a "Back one level" the handler declines to perform.
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
useKeyScope("plexBrowse", canStepBack);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
dispatch(e, "plexBrowse", {
back: (ev) => {
if (!canStepBack) return;
ev.preventDefault();
sub.back();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sub, canStepBack]);
// 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<SortState | null>(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) {
// Not adjustable during render, which is where a state reset like this would otherwise
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
// — the guard above is false immediately afterwards.
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
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<HTMLDivElement | null>(null);
const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ;
// A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt
// on every settle and would re-request it once a round-trip while the sentinel is in view.
const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError;
useEffect(() => {
if (!sentinelEl) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && mayReadAhead(hasNextPage, isFetching))
void fetchNextPage(NEXT_PAGE);
},
{ rootMargin: "600px" },
);
io.observe(sentinelEl);
return () => io.disconnect();
}, [sentinelEl, hasNextPage, isFetching, 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 });
}
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
function onRestartCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "player", id: card.id, fromStart: true });
}
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
rememberInfoScroll(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") rememberInfoScroll(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<InfiniteData<PlexUnifiedResult>>({ 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() }),
});
const actions = usePlexCardActions(
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
menu.open,
);
const epActions = usePlexCardActions(
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
epMenu.open,
);
// 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<PlexFilters>) {
const merged = { ...filters } as unknown as Record<string, unknown>;
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
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");
rememberInfoScroll(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 (
<Suspense
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
>
<PlexPlayer
itemId={sub.view.id}
queue={sub.view.queue}
fromStart={sub.view.fromStart}
onClose={sub.back}
/>
</Suspense>
);
}
if (sub.view.kind === "playlist") {
const pid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexPlaylistView
playlistId={pid}
onBack={sub.back}
onPlay={(itemRk, queue, fromStart) =>
sub.open({ kind: "player", id: itemRk, queue, fromStart })
}
onInfoCard={onInfo}
/>
</Suspense>
);
}
if (sub.view.kind === "collection") {
const cid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexCollectionView
collectionId={cid}
onBack={sub.back}
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
/>
</Suspense>
);
}
if (sub.view.kind === "info") {
const infoId = sub.view.id;
return (
<PlexInfoView
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
);
}
if (sub.view.kind === "person") {
const personName = sub.view.name;
return (
// Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio).
<PlexPersonView
key={personName}
name={personName}
onBack={sub.back}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onFilter={applyFilter}
/>
);
}
if (sub.view.kind === "show") {
const showId = sub.view.id;
return (
// Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog).
<PlexShowView
key={showId}
showId={showId}
onBack={sub.back}
onPlay={(epRk, queue, fromStart) =>
sub.open({ kind: "player", id: epRk, queue, fromStart })
}
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 (
<PlexSeasonView
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onPlay={(epRk, queue, fromStart) =>
sub.open({ kind: "player", id: epRk, queue, fromStart })
}
/>
);
}
// 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 = (
<SortControl
options={sortOptions}
sort={sortState}
onChange={(next) => 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 (
<div className="p-4 max-w-400 mx-auto">
<PlexDiscover
scope={scope}
onOpenCard={onCard}
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })}
/>
</div>
);
}
// 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 (
<div className="p-4 max-w-400 mx-auto">
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
</div>
);
}
if (sub.view.kind === "collections") {
return (
<div className="p-4 max-w-400 mx-auto">
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
</div>
);
}
const showOptions = SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }));
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
const pageMenuItems = menuGroups(
[
sortSubmenu({
id: "sort",
label: t("plex.filter.sort"),
options: sortOptions,
sort: sortState,
onChange: (next) => setSort(next ?? PLEX_SORT_FALLBACK),
ascLabel: t("plex.filter.dir.asc"),
descLabel: t("plex.filter.dir.desc"),
}),
choiceSubmenu({
id: "show",
label: t("plex.filter.show"),
options: showOptions,
selected: show,
onPick: setShow,
}),
],
plexChips.length > 0
? [{ id: "clear", label: t("sidebar.clearAll"), onSelect: clearAllFilters }]
: [],
);
return (
<div className="p-4 max-w-400 mx-auto">
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-400 mx-auto">
<div className="flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={plexChips.length} />
{/* 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. */}
{!fold.folded && (
<FilterSelect
label={t("plex.filter.show")}
testId="plex-show"
options={showOptions}
selected={[show]}
onChange={(next) => setShow(next[0] ?? "all")}
/>
)}
{/* Folded, the band is one row: Sort + the view toggle join it on the right. */}
{fold.folded && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
{/* 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. */}
{!fold.folded && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
<SelectedViewChip />
<ActiveFilterChips
filters={plexChips}
onClearAll={clearAllFilters}
className="flex-1"
/>
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
{personCards.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{personCards.map((p) => (
<div
key={p.name}
className={`${SURFACE.card} flex items-center gap-1 py-1.5 pl-1.5 pr-2.5`}
>
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<Pressable
selectable
onClick={() => onOpenPerson(p.name)}
title={t("plex.info.viewPerson", { name: p.name })}
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
{p.name}
</div>
<div className="text-2xs text-muted">
{t("plex.people.count", { count: p.count })}
</div>
</div>
</Pressable>
<IconButton
onClick={() => removeActor(p.name)}
label={t("plex.people.remove")}
className="ml-1"
>
<X />
</IconButton>
</div>
))}
</div>
)}
{browseQ.isError && !browseQ.data ? (
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : 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".
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
{t("plex.filter.clearAll")}
</Chip>
</div>
) : (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
)
) : (
<>
{/* Titles grid — movies + shows mixed (visually tagged). */}
{items.length > 0 && (
<>
{episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
)}
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
{items.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
))}
</NavGrid>
</>
)}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onRestart={() => onRestartCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
onAdd={() => epActions.add(ep)}
menuProps={epMenu.triggerProps}
menuButtonProps={epMenu.buttonProps}
menuOpen={epMenu.open && epMenu.target === ep}
/>
))}
</NavGrid>
</section>
)}
</>
)}
<PlexMenu menu={menu} actions={actions} />
<PlexMenu menu={epMenu} actions={epActions} pin={false} />
<div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
)}
{isFetchNextPageError && !isFetchingNextPage && (
<StateMessage
tone="error"
compact
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
>
{t("common.loadMoreFailed")}
</StateMessage>
)}
</div>
);
}