refactor(frontend): each page fills the floating header itself; the header knows no page

The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
This commit is contained in:
2026-10-23 00:23:38 +02:00
parent 0b24f2d8fa
commit 4ef963b005
36 changed files with 530 additions and 316 deletions
@@ -64,6 +64,7 @@ import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { useWizard } from "../../features/wizard/wizardContext";
import { useMe } from "../../lib/useMe";
import { useTags } from "../../lib/useTags";
import ChannelsHeader from "./ChannelsHeader";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
@@ -687,6 +688,7 @@ export default function Channels() {
return (
<>
<ChannelsHeader />
{pageMenuItems.length > 0 && (
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
)}
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe";
import { PageHeader } from "../../components/ui/PageShell";
import SearchBar from "../../features/header/SearchBar";
import { blurActive } from "../../features/header/blurActive";
import { useChannels, useChannelsActions } from "./channelsContext";
import SyncStatus from "./SyncStatus";
// The channel manager's pills in the floating header: the name filter and the per-user sync chip.
export default function ChannelsHeader() {
const { t } = useTranslation();
const me = useMe();
const { search } = useChannels();
const { setSearch, goToFullHistory } = useChannelsActions();
return (
<PageHeader>
<SearchBar
value={search}
placeholder={t("channels.searchPlaceholder")}
onChange={setSearch}
onGo={blurActive}
/>
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={goToFullHistory} />
</PageHeader>
);
}
+3 -1
View File
@@ -1,3 +1,5 @@
// The channel manager's public face: its store, mounted at the root and read app-wide.
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
// chip, which the feed's header shows too.
export { ChannelsProvider } from "./ChannelsProvider";
export { useChannels, useChannelsActions } from "./channelsContext";
export { default as SyncStatus } from "./SyncStatus";
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
//
// The live-search button's tooltip says what a search costs under the search source the instance
// actually runs. It used to say "uses shared API quota" always, while the default source (the
// website's own endpoint) spends none — so the owner avoided a free search for months.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Me } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
let me = {} as Me;
vi.mock("../../lib/useMe", () => ({ useMe: () => me }));
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ enterYtSearch: vi.fn() }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ q: "cats", sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
}));
vi.mock("../channels", () => ({
useChannelsActions: () => ({ goToFullHistory: vi.fn() }),
SyncStatus: () => null,
}));
vi.mock("./FeedScopeToggle", () => ({ default: () => null }));
vi.mock("../../components/ui/PageScroller", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import FeedHeader from "./FeedHeader";
import PageShell from "../../components/ui/PageShell";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function tipFor(source: Me["search_source"]): string | null {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
me = { search_source: source, role: "user" } as Me;
render(
<PageShell header={<div />}>
<FeedHeader />
</PageShell>,
);
return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title");
}
describe("the live-search tooltip", () => {
it("does not claim a quota cost under the free website source", () => {
expect(tipFor("scrape")).toBe("header.searchYoutubeTip");
});
it("names the quota cost when the instance searches through the official API", () => {
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../../lib/brandIcons";
import { useMe } from "../../lib/useMe";
import { PageHeader } from "../../components/ui/PageShell";
import SearchBar from "../../features/header/SearchBar";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { SyncStatus, useChannelsActions } from "../channels";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import FeedScopeToggle from "./FeedScopeToggle";
// The feed's pills in the floating header: the search box (whose Go runs a live YouTube search for
// the term), the Mine/Library scope, and the per-user sync chip.
export default function FeedHeader() {
const { t } = useTranslation();
const me = useMe();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const { enterYtSearch } = useNavigationActions();
const { goToFullHistory } = useChannelsActions();
const trimmed = filters.q.trim();
return (
<PageHeader>
<SearchBar
value={filters.q}
placeholder={t("header.searchPlaceholder")}
onChange={(q) => {
// When a search first appears, rank the feed by relevance — set atomically with the
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
// sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onGo={() => enterYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed}
/>
{/* Mine/Library scope sits right of the search box — relocated here from the filter sidebar so
the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
<FeedScopeToggle />
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
pills rather than pinned to the far right: on a wide window that just strands it. Only on
the two pages it says anything about: the feed those subscriptions fill, and the manager
where you curate them. */}
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={goToFullHistory} />
</PageHeader>
);
}
+4 -1
View File
@@ -5,13 +5,15 @@ import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import FeedHeader from "./FeedHeader";
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
export const Feed = lazyWithPreload(() => import("./Feed"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the
// header (the channel page's feed has no header). (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
export function FeedPage() {
const me = useMe();
@@ -27,6 +29,7 @@ export function FeedPage() {
const { modalOpen } = useSavedViews();
return (
<>
<FeedHeader />
<Feed
filters={filters}
setFilters={setFilters}
+2
View File
@@ -48,6 +48,7 @@ import Pager from "../../components/ui/Pager";
import { Pressable } from "../../components/ui/Pressable";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useContextMenu } from "../../components/ui/useContextMenu";
import FilesHeader from "./FilesHeader";
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
// particular is heavy — filmstrip + scrubber).
const UploadDialog = lazy(() => import("./UploadDialog"));
@@ -654,6 +655,7 @@ export default function Files() {
return (
<>
<FilesHeader />
<PageToolbar>
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
{renamingFolder && open?.kind === "own" ? (
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { PageHeader } from "../../components/ui/PageShell";
import SearchBar from "../../features/header/SearchBar";
import { blurActive } from "../../features/header/blurActive";
import { useFiles, useFilesActions } from "./filesContext";
// The files library's pill in the floating header: its name filter.
export default function FilesHeader() {
const { t } = useTranslation();
const { search } = useFiles();
const { setSearch } = useFilesActions();
return (
<PageHeader>
<SearchBar
value={search}
placeholder={t("files.searchPlaceholder")}
onChange={setSearch}
onGo={blurActive}
/>
</PageHeader>
);
}
@@ -12,6 +12,8 @@ const h = vi.hoisted(() => ({
notify: vi.fn(),
confirm: vi.fn(async () => true),
selectedId: 1 as number | null,
// Who reads the selection, so a test's change re-renders them the way the real store does.
selectionReaders: new Set<() => void>(),
mounted: false,
mounts: 0,
player: null as null | {
@@ -36,7 +38,20 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./playlistsContext", async () => {
const { useSyncExternalStore } = await import("react");
const subscribe = (read: () => void) => {
h.selectionReaders.add(read);
return () => void h.selectionReaders.delete(read);
};
return {
usePlaylists: () => ({
selectedId: useSyncExternalStore(subscribe, () => h.selectedId),
search: "",
}),
usePlaylistsActions: () => ({ setSearch: () => {} }),
};
});
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
// The list's own menu is usePlaylistActions' (pinned in its own test); here its rename row, which
// opens THIS page's title field, and its two destructive rows as that hook marks them (danger).
@@ -143,7 +158,14 @@ function mount(
);
const tree = () => (strictOverCache ? <StrictMode>{page()}</StrictMode> : page());
const view = render(tree());
return { rerender: () => view.rerender(tree()) };
// A changed `h.selectedId` reaches the page as a store change, not as a re-render from the top: the
// page's own parts re-render because they read the selection, as in the app.
return {
rerender: () => {
act(() => h.selectionReaders.forEach((read) => read()));
view.rerender(tree());
},
};
}
const play = async (title: string) =>
@@ -68,6 +68,7 @@ import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
import { PageHeading } from "../../components/ui/Headings";
import { useCopyItems } from "../../components/ui/copyItems";
import RemoteImage from "../../components/ui/RemoteImage";
import PlaylistsHeader from "./PlaylistsHeader";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -160,7 +161,18 @@ function Row({
);
}
// The page: its header pill, and the selected playlist's detail (whose loading and empty states
// return early, so the header sits outside it).
export default function Playlists() {
return (
<>
<PlaylistsHeader />
<PlaylistDetail />
</>
);
}
function PlaylistDetail() {
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists();
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { PageHeader } from "../../components/ui/PageShell";
import SearchBar from "../../features/header/SearchBar";
import { blurActive } from "../../features/header/blurActive";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
// The playlists page's pill in the floating header: the rail's name filter.
export default function PlaylistsHeader() {
const { t } = useTranslation();
const { search } = usePlaylists();
const { setSearch } = usePlaylistsActions();
return (
<PageHeader>
<SearchBar
value={search}
placeholder={t("playlists.searchPlaceholder")}
onChange={setSearch}
onGo={blurActive}
/>
</PageHeader>
);
}
+17 -15
View File
@@ -43,7 +43,7 @@ import { useFilterFold } from "../../lib/useFilterFold";
import { StateMessage } from "../../components/ui/QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./plexContext";
import { usePublishPlexView } from "./plexViewContext";
import PlexHeader from "./PlexHeader";
import { EpisodeCard, PlexPosterCard } from "../../features/plexCards/plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "../../components/ui/useContextMenu";
@@ -64,7 +64,7 @@ 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
// search term comes from its header search box (q, PlexHeader). 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.
@@ -96,6 +96,20 @@ const BACKABLE_VIEWS: readonly string[] = [
];
export default function PlexBrowse() {
// Discover is the landing; the flat grid is the "Browse all" secondary view. The sub-view lives
// here, above both the header and the view, because the header's toggles drive it. It rides
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
// clears ?v and drops _sub: every plex entry starts at the Discover root).
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
return (
<>
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
<PlexBrowseView sub={sub} />
</>
);
}
function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>> }) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
@@ -122,19 +136,7 @@ export default function PlexBrowse() {
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]);
// A header search is a browse intent → flip to the grid (below); an explicit toggle returns.
// 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.
+19
View File
@@ -0,0 +1,19 @@
import { PageHeader } from "../../components/ui/PageShell";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
import type { PlexViewControls } from "./plexSubUrl";
// Plex's pills in the floating header: the search box, then the library scope (movie/show/both, plus
// the playlists/collections surfaces) and the Discover ⟷ Browse switch — relocated here from the
// filter sidebar so the primary "what am I browsing" controls are always in reach. Both toggles drive
// PlexBrowse's sub-view, which PlexBrowse hands down as `view`.
export default function PlexHeader({ view }: { view: PlexViewControls }) {
return (
<PageHeader>
<PlexSearchBox />
<PlexScopeToggle view={view} />
<PlexViewToggle view={view} />
</PageHeader>
);
}
@@ -2,8 +2,8 @@
//
// Contract of the header library-scope pill (scopes + browse modes): it renders the
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
// picked scope to the provider, and drives the playlists/collections views through the `view` it is handed.
// i18n is key-passthrough and the provider is mocked; SegmentedToggle is a plain
// button group (no floating-ui), so no observer stubs are needed.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
@@ -20,13 +20,11 @@ vi.mock("./plexContext", () => ({
usePlexActions: () => ({ setScope }),
}));
// The bridge: a controllable current view kind + an open spy.
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./plexViewContext", () => ({
usePlexViewControls: () => controls,
}));
// The sub-view: a controllable current view kind + an open spy.
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
import PlexScopeToggle from "./PlexScopeToggle";
import type { PlexViewControls } from "./plexSubUrl";
beforeEach(() => {
plexState.scope = "both";
@@ -48,7 +46,7 @@ const pressed = () =>
describe("PlexScopeToggle", () => {
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
plexState.scope = "movie";
render(<PlexScopeToggle />);
render(<PlexScopeToggle view={controls} />);
expect(labels()).toEqual([
"plex.filter.scopeOpt.both",
"plex.filter.scopeOpt.movie",
@@ -60,14 +58,14 @@ describe("PlexScopeToggle", () => {
});
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
render(<PlexScopeToggle />);
render(<PlexScopeToggle view={controls} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
expect(setScope).toHaveBeenCalledWith("show");
expect(controls.open).not.toHaveBeenCalled();
});
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
render(<PlexScopeToggle />);
render(<PlexScopeToggle view={controls} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
expect(setScope).not.toHaveBeenCalled();
@@ -75,13 +73,13 @@ describe("PlexScopeToggle", () => {
it("marks the Collection mode active when the current view is collections", () => {
controls.kind = "collections";
render(<PlexScopeToggle />);
render(<PlexScopeToggle view={controls} />);
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
});
it("returns to the title grid when a scope is picked from a browse mode", () => {
controls.kind = "playlists";
render(<PlexScopeToggle />);
render(<PlexScopeToggle view={controls} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
expect(setScope).toHaveBeenCalledWith("movie");
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
@@ -1,34 +1,33 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { usePlex, usePlexActions } from "./plexContext";
import { usePlexViewControls } from "./plexViewContext";
import type { PlexViewControls } from "./plexSubUrl";
// The Plex "browse target" as a compact header control next to the search box: the three library
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
// modes drive PlexBrowse's sub-view (`view`, from PlexHeader). Picking a scope while in a
// playlists/collections surface returns to the title grid at that scope.
const SCOPES = ["both", "movie", "show"] as const;
export default function PlexScopeToggle() {
export default function PlexScopeToggle({ view: ctl }: { view: PlexViewControls }) {
const { t } = useTranslation();
const { scope } = usePlex();
const { setScope } = usePlexActions();
const ctl = usePlexViewControls();
const inPlaylists = ctl?.kind === "playlists";
const inCollections = ctl?.kind === "collections";
const inPlaylists = ctl.kind === "playlists";
const inCollections = ctl.kind === "collections";
// The active segment: the browse-mode when in one, else the library scope.
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
const onChange = (v: string) => {
if (v === "playlists" || v === "collections") {
ctl?.open({ kind: v });
ctl.open({ kind: v });
return;
}
setScope(v);
// Leaving the playlists/collections surface for a title scope → show the title grid.
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
if (inPlaylists || inCollections) ctl.open({ kind: "grid" });
};
return (
+1 -1
View File
@@ -17,7 +17,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
// below it. Kept out of the generic SearchBar (and out of the shell's header) so all Plex-specific wiring —
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
@@ -1,28 +0,0 @@
import { useState, type ReactNode } from "react";
import {
PlexViewPublishContext,
PlexViewReadContext,
type PlexViewControls,
} from "./plexViewContext";
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
// Header READS it to render the toggle beside the library-scope pill.
//
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
// reseed; the bridge only mirrors the small slice the header needs.
//
// The contexts + hooks live in plexViewContext.ts.
export function PlexViewBridge({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<PlexViewControls | null>(null);
return (
<PlexViewPublishContext.Provider value={setControls}>
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
</PlexViewPublishContext.Provider>
);
}
@@ -3,7 +3,7 @@
// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none,
// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards
// against). Also: it only renders on the two title-browse views, marks the active one, and drives
// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked.
// PlexBrowse's sub-view through the `view` it is handed. i18n is key-passthrough.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
@@ -12,12 +12,10 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./plexViewContext", () => ({
usePlexViewControls: () => controls,
}));
const controls = { kind: "discover" as PlexViewControls["kind"], open: vi.fn() };
import PlexViewToggle from "./PlexViewToggle";
import type { PlexViewControls } from "./plexSubUrl";
beforeEach(() => {
controls.kind = "discover";
@@ -27,13 +25,13 @@ afterEach(cleanup);
describe("PlexViewToggle", () => {
it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => {
const { container } = render(<PlexViewToggle />);
const { container } = render(<PlexViewToggle view={controls} />);
// Without this class the whole toggle is click-dead inside the pointer-events-none header.
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
});
it("renders the two options with the current view pressed", () => {
render(<PlexViewToggle />);
render(<PlexViewToggle view={controls} />);
const btns = [
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
];
@@ -45,21 +43,21 @@ describe("PlexViewToggle", () => {
});
it("opens the grid view when Browse is picked", () => {
render(<PlexViewToggle />);
render(<PlexViewToggle view={controls} />);
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
});
it("opens the discover view when Discover is picked from the grid", () => {
controls.kind = "grid";
render(<PlexViewToggle />);
render(<PlexViewToggle view={controls} />);
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
});
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
controls.kind = "playlists";
const { container } = render(<PlexViewToggle />);
const { container } = render(<PlexViewToggle view={controls} />);
expect(container.firstChild).toBeNull();
});
});
+4 -5
View File
@@ -1,16 +1,15 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { usePlexViewControls } from "./plexViewContext";
import type { PlexViewControls } from "./plexSubUrl";
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on
// It reflects + drives PlexBrowse's top-level sub-view (`view`, from PlexHeader), and hides itself on
// the deeper views (a show/info/person page has no Discover/Browse choice).
export default function PlexViewToggle() {
export default function PlexViewToggle({ view: ctl }: { view: PlexViewControls }) {
const { t } = useTranslation();
const ctl = usePlexViewControls();
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
// and the playlists/collections browse surfaces hide it.
if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null;
if (ctl.kind !== "discover" && ctl.kind !== "grid") return null;
// The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the
// content below); every interactive child must re-enable pointer events or it is click-dead — the
// sibling PlexScopeToggle does the same.
-1
View File
@@ -1,5 +1,4 @@
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
export { PlexProvider } from "./PlexProvider";
export { PlexViewBridge } from "./PlexViewBridge";
export { usePlex, usePlexActions } from "./plexContext";
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
+7
View File
@@ -4,6 +4,13 @@ import { parsePositiveIntId } from "../../lib/parseId";
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
// (and its lazy hls.js player) into the test graph.
// What the header's toggles get of PlexBrowse's sub-view: the current top-level view (they derive
// their state from it) and the opener (they drive it).
export type PlexViewControls = {
kind: Sub["kind"];
open: (v: Sub) => void;
};
export type Sub =
| { kind: "discover" }
| { kind: "grid" }
@@ -1,15 +0,0 @@
import { createContext, useContext } from "react";
import { type Sub } from "./plexSubUrl";
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
// out of that file so it exports only components (Fast Refresh).
export type PlexViewControls = {
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
open: (v: Sub) => void;
};
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
export const usePlexViewControls = () => useContext(PlexViewReadContext);
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
@@ -60,8 +60,8 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page's identity is
// the nav rail and the tab title; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel() {
const me = useMe();
const { t } = useTranslation();