Files
siftlode/frontend/src/components/PlexBrowse.tsx
T
peter 8776586ca3 fix(plex): a matched episode and a collection member offer Details in their menu
Their titles open the item's page, a pointer-only door; the card menu is the keyboard's.
2026-10-09 23:46:39 +02:00

799 lines
36 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 { usePlayShow } from "./usePlayShow";
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;
// 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; 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]);
// 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]);
const resolveShow = usePlayShow(
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
(id) => sub.open({ kind: "show", id }),
sub.view,
);
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
function playShow(card: PlexCard, fromStart: boolean) {
scrollRef.current = scroller()?.scrollTop ?? 0;
void resolveShow(card, fromStart);
}
function onCard(card: PlexCard) {
if (card.type === "show") return playShow(card, false);
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
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) {
if (card.type === "show") return playShow(card, true);
scrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "player", id: card.id, fromStart: true });
}
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
// and a card title's link).
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
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,
);
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
const epActions = usePlexCardActions(
{ onOpen: onCard, onRestart: onRestartCard, onInfo, 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}
// Up to the show or season: Back returns to this page where it was (as from a cast row).
onOpenShow={(showId) => {
rememberInfoScroll(scroller()?.scrollTop ?? 0);
sub.open({ kind: "show", id: showId });
}}
onOpenSeason={(showId, seasonId) => {
rememberInfoScroll(scroller()?.scrollTop ?? 0);
sub.open({ kind: "season", showId, seasonId });
}}
// A collection strip's title: another info page, from its top.
onOpenInfo={(id) => {
rememberInfoScroll(0);
sub.open({ kind: "info", id });
}}
/>
);
}
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 })}
onPlayShow={playShow}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
);
}
if (sub.view.kind === "season") {
const { showId, seasonId } = sub.view;
return (
<PlexSeasonView
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onInfoCard={onInfo}
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)}
onPage={onInfo}
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)}
onPage={() => onInfo(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>
);
}