diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx
index 3d58d980..ccde6e71 100644
--- a/frontend/src/components/Playlists.dom.test.tsx
+++ b/frontend/src/components/Playlists.dom.test.tsx
@@ -37,14 +37,16 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
}));
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
-// The list's own menu is usePlaylistActions' (pinned in its own test); here only its rename row,
-// which opens THIS page's title field.
+// 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).
vi.mock("./usePlaylistActions", () => ({
usePlaylistActions: () => ({
remove: vi.fn(),
rename: vi.fn(),
items: (_pl: unknown, o: { onRename: () => void }) => [
{ id: "rename", label: "playlists.rename", onSelect: o.onRename },
+ { id: "revert", label: "playlists.revert", danger: true, onSelect: vi.fn() },
+ { id: "delete", label: "playlists.delete", danger: true, onSelect: vi.fn() },
],
}),
}));
@@ -67,6 +69,7 @@ vi.mock("./PlayerModal", async () => {
import Playlists from "./Playlists";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
+import { PageMenuSlot, type PageMenuOpener } from "./ui/pageMenuSlot";
import { api, HttpError, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
@@ -633,3 +636,46 @@ describe("Playlists · the header", () => {
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
});
});
+
+// DESIGN "Background menu": the page's blank area mirrors the band (Play all, the view controls) and
+// the list's own "…" — without its destructive rows, which stay one deliberate "…" away.
+describe("Playlists · the page's background menu", () => {
+ it("offers Play all, Group by and the list's menu, never Delete", async () => {
+ let open: PageMenuOpener | null = null;
+ vi.spyOn(api, "playlists").mockResolvedValue([list(1, [vid("A", 0), vid("B", 0)])] as never);
+ vi.spyOn(api, "playlist").mockImplementation(
+ async () => list(1, [vid("A", 0), vid("B", 0)]) as never,
+ );
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: {} });
+ render(
+
+ {
+ open = o;
+ return () => {
+ if (open === o) open = null;
+ };
+ }}
+ >
+
+
+ ,
+ );
+ await screen.findByRole("button", { name: "card.play — Video A" });
+ expect(open, "registered once a list is on screen").not.toBeNull();
+ act(() => open!(5, 5, document.body));
+ const rows = await waitFor(() => {
+ const r = screen
+ .getAllByRole("menu", { hidden: true })
+ .find((m) => m.getAttribute("aria-label") === "common.pageMenu");
+ expect(r).toBeTruthy();
+ return [...r!.querySelectorAll("[role^=menuitem]")].map((b) => b.textContent);
+ });
+ expect(rows).toEqual(
+ expect.arrayContaining(["playlists.playAll", "playlists.groupByChannel", "playlists.rename"]),
+ );
+ expect(rows).not.toContain("playlists.delete");
+ expect(rows).not.toContain("playlists.revert");
+ });
+});
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index fe74bd26..5698bbaa 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -35,6 +35,8 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinPlaylist, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
+import { usePageMenu } from "./ui/pageMenuSlot";
+import { menuGroups } from "./ui/menuGroups";
import UndoToolbar from "./ui/UndoToolbar";
import NavGrid from "./ui/NavGrid";
import { PageToolbar } from "./ui/PageShell";
@@ -268,6 +270,9 @@ export default function Playlists() {
refetchOnWindowFocus: true,
});
const detail = detailQuery.data;
+ // The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
+ const pageMenu = usePageMenu(selectedId != null && !!detail);
+ const pagePin = useComfortPin(pageMenu.open);
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
// the object around them is rebuilt every render (lib/useUndoable).
@@ -704,6 +709,57 @@ export default function Playlists() {
pin: headerPin.item(pinPlaylist(headerPl.id), plName(headerPl)),
});
+ // The page's background menu mirrors the band: Play all, the view controls, the order's undo, and
+ // the list's own "…" without its destructive rows (Delete, Revert) — a stray right-click on the
+ // page's blank area must not be one click from those.
+ const pageItems: ActionMenuItem[] = !detail
+ ? []
+ : menuGroups(
+ [
+ {
+ id: "playAll",
+ label: t("playlists.playAll"),
+ disabled: !shown.length,
+ onSelect: () => shown.length && openPlayer(shown[0]!.id),
+ },
+ ],
+ items.length > 1 || viewActive
+ ? [
+ {
+ id: "groupBy",
+ label: t("playlists.groupByChannel"),
+ check: { kind: "many", checked: groupBy },
+ onSelect: () => setGroupBy(!groupBy),
+ },
+ ...(viewActive
+ ? [{ id: "clearView", label: t("playlists.clearView"), onSelect: clearView }]
+ : []),
+ ...(canSaveOrder
+ ? [{ id: "saveOrder", label: t("playlists.saveOrder"), onSelect: saveOrder }]
+ : []),
+ ]
+ : [],
+ canEdit && (order.canUndo || order.canRedo)
+ ? [
+ ...(order.canUndo
+ ? [{ id: "undo", label: t("common.undo"), onSelect: order.undo }]
+ : []),
+ ...(order.canRedo
+ ? [{ id: "redo", label: t("common.redo"), onSelect: order.redo }]
+ : []),
+ ]
+ : [],
+ plActions
+ .items(detail, {
+ onRename: () => {
+ setRenameValue(detail.name);
+ setRenaming(true);
+ },
+ pin: pagePin.item(pinPlaylist(detail.id), plName(detail)),
+ })
+ .filter((it) => !("danger" in it && it.danger)),
+ );
+
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
// another list under an open player (the command palette works over it) puts the page into
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
@@ -966,6 +1022,11 @@ export default function Playlists() {
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
+
{player}
>
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index a0da74e8..fa585957 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -30,6 +30,9 @@ import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./ui/PageShell";
+import { PageMenu } from "./ui/PageMenu";
+import { menuGroups } from "./ui/menuGroups";
+import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ui/ItemCount";
@@ -531,8 +534,35 @@ export default function PlexBrowse() {
);
}
+ const showOptions = SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }));
+ // The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
+ const pageMenuItems = menuGroups(
+ [
+ sortSubmenu({
+ id: "sort",
+ label: t("plex.filter.sort"),
+ options: sortOptions,
+ sort: sortState,
+ onChange: (next) => setSort(next ?? PLEX_SORT_FALLBACK),
+ ascLabel: t("plex.filter.dir.asc"),
+ descLabel: t("plex.filter.dir.desc"),
+ }),
+ choiceSubmenu({
+ id: "show",
+ label: t("plex.filter.show"),
+ options: showOptions,
+ selected: show,
+ onPick: setShow,
+ }),
+ ],
+ plexChips.length > 0
+ ? [{ id: "clear", label: t("sidebar.clearAll"), onSelect: clearAllFilters }]
+ : [],
+ );
+
return (