diff --git a/frontend/src/components/PlexScopeToggle.dom.test.tsx b/frontend/src/components/PlexScopeToggle.dom.test.tsx
index 7ba75aec..e7ff1324 100644
--- a/frontend/src/components/PlexScopeToggle.dom.test.tsx
+++ b/frontend/src/components/PlexScopeToggle.dom.test.tsx
@@ -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();
- 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();
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();
+ 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();
+ 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();
+ fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
+ expect(setScope).toHaveBeenCalledWith("movie");
+ expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
});
});
diff --git a/frontend/src/components/PlexScopeToggle.tsx b/frontend/src/components/PlexScopeToggle.tsx
index 93e58a78..6d436a69 100644
--- a/frontend/src/components/PlexScopeToggle.tsx
+++ b/frontend/src/components/PlexScopeToggle.tsx
@@ -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 (
);
diff --git a/frontend/src/components/PlexViewBridge.tsx b/frontend/src/components/PlexViewBridge.tsx
index 1c1d73d9..99f2cb41 100644
--- a/frontend/src/components/PlexViewBridge.tsx
+++ b/frontend/src/components/PlexViewBridge.tsx
@@ -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;
};
diff --git a/frontend/src/components/PlexViewToggle.tsx b/frontend/src/components/PlexViewToggle.tsx
index a2ba5a7b..b9b5ef33 100644
--- a/frontend/src/components/PlexViewToggle.tsx
+++ b/frontend/src/components/PlexViewToggle.tsx
@@ -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 (
ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx
new file mode 100644
index 00000000..437fd421
--- /dev/null
+++ b/frontend/src/components/plexBrowseLists.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+ q.refetch()}>
+ {t("common.loadError")}
+
+ );
+ }
+ if (q.isLoading) return ;
+ if (playlists.length === 0)
+ return