feat(plex): add Playlist/Collection browse modes to the header pill

The header library-scope pill gains two entry points — Playlist and Collection —
next to All/Movies/Shows, a second way into each area (the rail filter is the
other). Picking Playlist shows a grid of the user's playlists (a tile opens that
playlist's view); Collection shows a searchable grid of collections (a tile
filters the title grid by that collection). Pure front-end — reuses the existing
plexPlaylists / plexCollections(union) list endpoints.

Two new top-level sub-views (`playlists` / `collections`, bookmarkable as
?v=playlists / ?v=collections) rendered by PlexBrowse via the new plexBrowseLists
surfaces (hue-tiled like the Discover genre tiles). The PlexViewBridge now carries
the view `kind`; PlexScopeToggle is a 5-way that derives its active segment from
it (browse-mode when in one, else scope) and drives the two views through the
bridge — picking a scope from a browse mode returns to the title grid. The
Discover/Browse toggle hides in these modes; Backspace + the search→grid flip
cover them.

Tests: PlexScopeToggle (5-way + browse-mode drive/active), plexSubUrl round-trip
for the two new views. Gate: tsc/lint/knip/prettier + 402 vitest.
This commit is contained in:
2026-08-22 18:19:08 +02:00
parent f85dc12819
commit f983a491c0
10 changed files with 278 additions and 31 deletions
+38 -7
View File
@@ -27,6 +27,7 @@ import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import ItemCount from "./ItemCount";
@@ -87,11 +88,7 @@ export default function PlexBrowse() {
// 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,
});
publishView({ kind: sub.view.kind, open: sub.open });
return () => publishView(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sub.view.kind]);
@@ -127,7 +124,10 @@ export default function PlexBrowse() {
const browseIntent = !!dq;
const prevIntent = useRef(browseIntent);
useEffect(() => {
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
// 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;
@@ -162,7 +162,18 @@ export default function PlexBrowse() {
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
if (
[
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
].includes(sub.view.kind)
) {
e.preventDefault();
sub.back();
}
@@ -407,6 +418,26 @@ export default function PlexBrowse() {
</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-[1600px] mx-auto">
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
</div>
);
}
if (sub.view.kind === "collections") {
return (
<div className="p-4 max-w-[1600px] mx-auto">
<PlexCollectionsBrowse
onPick={(id, title) =>
applyFilter({ collections: [id], collectionTitles: { [id]: title } })
}
/>
</div>
);
}
return (
<div className="p-4 max-w-[1600px] mx-auto">
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
//
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
// floating-ui), so no observer stubs are needed.
// Contract of the header library-scope pill (S3 relocation + Piece 2 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
// 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";
@@ -19,32 +20,70 @@ vi.mock("./PlexProvider", () => ({
usePlexActions: () => ({ setScope }),
}));
// The bridge: a controllable current view kind + an open spy.
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./PlexViewBridge", () => ({
usePlexViewControls: () => controls,
}));
import PlexScopeToggle from "./PlexScopeToggle";
beforeEach(() => {
plexState.scope = "both";
controls.kind = "discover";
setScope.mockReset();
controls.open.mockReset();
});
afterEach(cleanup);
const buttons = () => [
...screen.getByRole("group", { name: "plex.filter.scope" }).querySelectorAll("button"),
];
const labels = () => buttons().map((b) => b.textContent);
const pressed = () =>
buttons()
.filter((b) => b.getAttribute("aria-pressed") === "true")
.map((b) => b.textContent);
describe("PlexScopeToggle", () => {
it("renders the three library scopes, current one pressed", () => {
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
plexState.scope = "movie";
render(<PlexScopeToggle />);
const group = screen.getByRole("group", { name: "plex.filter.scope" });
const btns = [...group.querySelectorAll("button")];
expect(btns.map((b) => b.textContent)).toEqual([
expect(labels()).toEqual([
"plex.filter.scopeOpt.both",
"plex.filter.scopeOpt.movie",
"plex.filter.scopeOpt.show",
"plex.filter.scopeOpt.playlists",
"plex.filter.scopeOpt.collections",
]);
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
expect(pressed()).toEqual(["plex.filter.scopeOpt.movie"]);
});
it("writes the picked scope back to the provider", () => {
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
render(<PlexScopeToggle />);
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 />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
expect(setScope).not.toHaveBeenCalled();
});
it("marks the Collection mode active when the current view is collections", () => {
controls.kind = "collections";
render(<PlexScopeToggle />);
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 />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
expect(setScope).toHaveBeenCalledWith("movie");
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
});
});
+30 -7
View File
@@ -1,25 +1,48 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlexViewControls } from "./PlexViewBridge";
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
// 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
// playlists/collections surface returns to the title grid at that scope.
const SCOPES = ["both", "movie", "show"] as const;
export default function PlexScopeToggle() {
const { t } = useTranslation();
const { scope } = usePlex();
const { setScope } = usePlexActions();
const ctl = usePlexViewControls();
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 });
return;
}
setScope(v);
// Leaving the playlists/collections surface for a title scope → show the title grid.
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
};
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("plex.filter.scope")}
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
value={scope}
onChange={setScope}
options={[
...SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) })),
{ value: "playlists", label: t("plex.filter.scopeOpt.playlists") },
{ value: "collections", label: t("plex.filter.scopeOpt.collections") },
]}
value={value}
onChange={onChange}
/>
</div>
);
+1 -2
View File
@@ -12,8 +12,7 @@ import { type Sub } from "./plexSubUrl";
// 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
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
open: (v: Sub) => void;
};
+4 -2
View File
@@ -8,7 +8,9 @@ import { usePlexViewControls } from "./PlexViewBridge";
export default function PlexViewToggle() {
const { t } = useTranslation();
const ctl = usePlexViewControls();
if (!ctl || !ctl.toggleable) return null;
// 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;
return (
<SegmentedToggle
size="md"
@@ -17,7 +19,7 @@ export default function PlexViewToggle() {
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={ctl.isDiscover ? "discover" : "grid"}
value={ctl.kind === "discover" ? "discover" : "grid"}
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
+129
View File
@@ -0,0 +1,129 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Layers, ListMusic } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState";
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
// union), so this is a pure front-end surface. Tiles are coloured by a title hash (no per-item art to
// fetch/proxy), matching the Discover genre tiles.
// A stable, pleasant hue per title string — same idea as PlexDiscover's genre tiles.
function hueOf(name: string): number {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return h;
}
function Tile({
title,
count,
icon,
onClick,
}: {
title: string;
count: ReactNode;
icon: ReactNode;
onClick: () => void;
}) {
const hue = hueOf(title);
return (
<button
type="button"
onClick={onClick}
title={title}
className="glass-hover group relative flex h-28 flex-col justify-end overflow-hidden rounded-xl p-3 text-left transition"
style={{
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
}}
>
<span className="absolute right-2 top-2 text-white/70">{icon}</span>
<span className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</span>
<span className="text-2xs text-white/70">{count}</span>
</button>
);
}
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
// A grid of the user's Plex playlists; a tile opens that playlist's view.
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const playlists = q.data?.playlists ?? [];
if (q.isError && !q.data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
if (playlists.length === 0)
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<div className={GRID}>
{playlists.map((p) => (
<Tile
key={p.id}
title={p.title}
count={t("plex.browse.items", { count: p.item_count })}
icon={<ListMusic className="h-4 w-4" />}
onClick={() => onOpen(p.id)}
/>
))}
</div>
);
}
// A grid of Plex collections (searchable — the union can run to hundreds); a tile filters the title
// grid by that collection (via the parent's onPick → applyFilter).
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const searchDeb = useDebounced(search.trim(), 300);
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
});
const collections = q.data?.collections ?? [];
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="glass-card mb-4 w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<div className={GRID}>
{collections.map((c) => (
<Tile
key={c.id}
title={c.title}
count={c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : ""}
icon={<Layers className="h-4 w-4" />}
onClick={() => onPick(c.id, c.title)}
/>
))}
</div>
)}
</div>
);
}
@@ -6,6 +6,8 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
describe("PLEX_SUB_URL encode", () => {
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4)
expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2)
expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2)
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
@@ -22,6 +24,8 @@ describe("PLEX_SUB_URL encode", () => {
describe("PLEX_SUB_URL round-trip", () => {
const cases: Sub[] = [
{ kind: "grid" },
{ kind: "playlists" },
{ kind: "collections" },
{ kind: "show", id: "s1" },
{ kind: "season", showId: "s1", seasonId: "se2" },
{ kind: "info", id: "m9" },
+10
View File
@@ -7,6 +7,8 @@ import { parsePositiveIntId } from "../lib/parseId";
export type Sub =
| { kind: "discover" }
| { kind: "grid" }
| { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode)
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
@@ -25,6 +27,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
switch (v.kind) {
case "grid":
return "browse";
case "playlists":
return "playlists";
case "collections":
return "collections";
case "show":
return `show:${v.id}`;
case "season":
@@ -46,6 +52,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
switch (kind) {
case "browse":
return { kind: "grid" };
case "playlists":
return { kind: "playlists" };
case "collections":
return { kind: "collections" };
case "show":
return arg ? { kind: "show", id: arg } : null;
case "info":
+6 -1
View File
@@ -27,7 +27,9 @@
"scopeOpt": {
"both": "All",
"movie": "Movies",
"show": "Shows"
"show": "Shows",
"playlists": "Playlists",
"collections": "Collections"
},
"show": "Show",
"showOpt": {
@@ -284,6 +286,9 @@
"label": "View",
"browse": "Browse"
},
"browse": {
"items": "{{count}} items"
},
"discover": {
"tab": "Discover",
"continueWatching": "Continue watching",
+6 -1
View File
@@ -27,7 +27,9 @@
"scopeOpt": {
"both": "Mind",
"movie": "Filmek",
"show": "Sorozatok"
"show": "Sorozatok",
"playlists": "Listák",
"collections": "Gyűjtemények"
},
"show": "Megjelenítés",
"showOpt": {
@@ -284,6 +286,9 @@
"label": "Nézet",
"browse": "Böngészés"
},
"browse": {
"items": "{{count}} elem"
},
"discover": {
"tab": "Felfedezés",
"continueWatching": "Folytatás",