From f983a491c0527b92a6b19f29920428c73054d1e3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 18:19:08 +0200 Subject: [PATCH] feat(plex): add Playlist/Collection browse modes to the header pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/PlexBrowse.tsx | 45 +++++- .../components/PlexScopeToggle.dom.test.tsx | 61 +++++++-- frontend/src/components/PlexScopeToggle.tsx | 37 ++++- frontend/src/components/PlexViewBridge.tsx | 3 +- frontend/src/components/PlexViewToggle.tsx | 6 +- frontend/src/components/plexBrowseLists.tsx | 129 ++++++++++++++++++ frontend/src/components/plexSubUrl.test.ts | 4 + frontend/src/components/plexSubUrl.ts | 10 ++ frontend/src/i18n/locales/en/plex.json | 7 +- frontend/src/i18n/locales/hu/plex.json | 7 +- 10 files changed, 278 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/plexBrowseLists.tsx diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 878b9225..f3e2235f 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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() { ); } + // 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 ( +
+ sub.open({ kind: "playlist", id })} /> +
+ ); + } + if (sub.view.kind === "collections") { + return ( +
+ + applyFilter({ collections: [id], collectionTitles: { [id]: title } }) + } + /> +
+ ); + } return (
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 (
({ 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} />
); 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

{t("plex.playlist.none")}

; + + return ( +
+ {playlists.map((p) => ( + } + onClick={() => onOpen(p.id)} + /> + ))} +
+ ); +} + +// 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 ( +
+ 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 ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? ( + + ) : collections.length === 0 ? ( +

{t("plex.filter.collectionEmpty")}

+ ) : ( +
+ {collections.map((c) => ( + } + onClick={() => onPick(c.id, c.title)} + /> + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index 4ed2f3fc..fdd6be2b 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -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=:", () => { 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" }, diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index 381a7bd2..4a558699 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -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 = { 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 = { 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": diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 4feda835..d08fde5a 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index e4d41545..ce4f834a 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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",