feat(plex): move the Discover/Browse toggle into the header

The Discover ⟷ Browse switch now sits in the header next to the library-scope
pill (All / Movies / Shows), instead of on the chip row / atop the Discover
surface.

It's driven through a small one-way PlexViewBridge: PlexBrowse still OWNS the
sub-view (useHistorySubview) and publishes its top-level view + opener to the
bridge; the always-mounted Header reads it to render PlexViewToggle. A global
sub-view provider was deliberately avoided — PlexBrowse's hook remounts on every
plex entry and re-seeds from history (a page switch clears ?v + drops _sub), so
each entry starts at the Discover root; lifting it out would let the view go
stale across navigation. The toggle hides on deeper views (show/info/person/…)
and on non-plex pages (bridge cleared on unmount). sortView is just SortControl
now; the grid toolbar no longer carries the toggle.

Verified: toggle in the header, switches discover⟷grid, hides on info + on Feed,
and Feed→Plex resets to Discover (no stale view); no console errors.
This commit is contained in:
2026-08-22 18:02:55 +02:00
parent 9798e506cf
commit f85dc12819
5 changed files with 99 additions and 41 deletions
+4 -1
View File
@@ -5,6 +5,7 @@ import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./NavigationProvider";
@@ -103,8 +104,10 @@ const Header = forwardRef<
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
{/* 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 modules it says anything about: the feed those subscriptions fill, and the manager
+18 -23
View File
@@ -25,7 +25,6 @@ import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
import SegmentedToggle from "./SegmentedToggle";
import FilterSelect from "./FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PageToolbar } from "./PageShell";
@@ -34,6 +33,7 @@ import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
@@ -82,6 +82,20 @@ export default function PlexBrowse() {
// 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. Keyed on the
// view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
const publishView = usePublishPlexView();
useLayoutEffect(() => {
publishView({
isDiscover: sub.view.kind === "discover",
toggleable: sub.view.kind === "discover" || sub.view.kind === "grid",
open: sub.open,
});
return () => publishView(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sub.view.kind]);
// 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(() => {
@@ -366,25 +380,10 @@ export default function PlexBrowse() {
);
}
// Discover ⟷ Browse switch, shown on both surfaces so either can return to the other.
const viewToggle = (
<SegmentedToggle
size="md"
ariaLabel={t("plex.view.label")}
options={[
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={sub.view.kind === "discover" ? "discover" : "grid"}
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
// Sort + the Discover/Browse toggle are tightly bound to the grid below, so on the grid view they
// live pinned to the right of the applied-filter chip row (directly above the cards), not on their
// own toolbar line — the same idiom as the Feed's `sortView`.
// 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}
@@ -393,15 +392,11 @@ export default function PlexBrowse() {
ascLabel={t("plex.filter.dir.asc")}
descLabel={t("plex.filter.dir.desc")}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
{viewToggle}
</>
);
if (sub.view.kind === "discover") {
return (
<div className="p-4 max-w-[1600px] mx-auto">
<div className="mb-4 flex items-center">{viewToggle}</div>
<PlexDiscover
scope={scope}
onOpenCard={onCard}
@@ -0,0 +1,33 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { type Sub } from "./plexSubUrl";
// 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.
export type PlexViewControls = {
isDiscover: boolean; // the Discover landing vs the flat Browse grid
toggleable: boolean; // only discover|grid host the toggle; deeper views (show/info/person/…) hide it
open: (v: Sub) => void;
};
const ReadCtx = createContext<PlexViewControls | null>(null);
const PublishCtx = createContext<(c: PlexViewControls | null) => void>(() => {});
export function PlexViewBridge({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<PlexViewControls | null>(null);
return (
<PublishCtx.Provider value={setControls}>
<ReadCtx.Provider value={controls}>{children}</ReadCtx.Provider>
</PublishCtx.Provider>
);
}
export const usePlexViewControls = () => useContext(ReadCtx);
export const usePublishPlexView = () => useContext(PublishCtx);
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import { usePlexViewControls } from "./PlexViewBridge";
// 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
// the deeper views (a show/info/person page has no Discover/Browse choice).
export default function PlexViewToggle() {
const { t } = useTranslation();
const ctl = usePlexViewControls();
if (!ctl || !ctl.toggleable) return null;
return (
<SegmentedToggle
size="md"
ariaLabel={t("plex.view.label")}
options={[
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={ctl.isDiscover ? "discover" : "grid"}
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
}
+3
View File
@@ -9,6 +9,7 @@ import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { PlexViewBridge } from "./components/PlexViewBridge";
import { ChannelsProvider } from "./components/ChannelsProvider";
import { FeedViewProvider } from "./components/FeedViewProvider";
import { PrefsProvider } from "./components/PrefsProvider";
@@ -61,6 +62,7 @@ const root =
<SavedViewsProvider>
<PlaylistsProvider>
<PlexProvider>
<PlexViewBridge>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
@@ -70,6 +72,7 @@ const root =
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexViewBridge>
</PlexProvider>
</PlaylistsProvider>
</SavedViewsProvider>