diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
index 7737765d..2916bc87 100644
--- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
@@ -183,6 +183,17 @@ function pressPicture(pointerType: "touch" | "mouse", x = 450) {
fireEvent.click(v, { clientX: x, clientY: 250 });
}
+// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
+// measured it (never, under jsdom), and a hidden element's accessible name is empty.
+const menuItem = (label: string) =>
+ waitFor(() => {
+ const row = screen
+ .queryAllByRole("menuitem", { hidden: true })
+ .find((b) => b.textContent === label);
+ expect(row).toBeDefined();
+ return row!;
+ });
+
describe("a tap on the picture (C3)", () => {
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
mount({ details: { A: item("A") } });
@@ -277,13 +288,58 @@ describe("the C5 row", () => {
"plex.player.help",
"plex.player.download",
];
- for (const r of rows) expect(screen.getByRole("button", { name: r }), r).toBeTruthy();
+ const menu = await screen.findByRole("menu", { hidden: true });
+ expect(
+ [...menu.querySelectorAll('[role="menuitem"]')].map(
+ (b) => b.getAttribute("aria-label") ?? b.textContent,
+ ),
+ ).toEqual(rows);
// A row closes the menu and opens its tool: Settings → the tuning panel.
- fireEvent.click(screen.getByRole("button", { name: "plex.player.settings" }));
- expect(screen.queryByRole("button", { name: "plex.player.stats.title" })).toBeNull();
+ fireEvent.click(await menuItem("plex.player.settings"));
+ await screen.findByRole("dialog", { name: "plex.player.settings" });
+ expect(
+ screen
+ .queryAllByRole("menuitem", { hidden: true })
+ .find((b) => b.textContent === "plex.player.stats.title") ?? null,
+ ).toBeNull();
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
});
+ // E28 review D3: the ⋯ was a hand-built
of buttons — no menu role, no arrow keys, and Escape
+ // closed it with the focus left on . It is a menu on the popover engine now.
+ it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => {
+ mount({ details: { A: item("A") } });
+ await playingAt("/media/A", 100);
+ const more = screen.getByLabelText("plex.player.more");
+ more.focus();
+ fireEvent.click(more);
+ const menu = await screen.findByRole("menu", { hidden: true });
+ const rows = [...menu.querySelectorAll
('[role="menuitem"]')];
+ await waitFor(() => expect(document.activeElement).toBe(rows[0]));
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(rows[1]);
+ fireEvent.keyDown(document.activeElement!, { key: "End" });
+ expect(document.activeElement).toBe(rows[rows.length - 1]);
+ fireEvent.keyDown(document.activeElement!, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
+ await waitFor(() => expect(document.activeElement).toBe(more));
+ });
+
+ it("gives the focus back to the ⋯ when the tuning panel closes from inside", async () => {
+ mount({ details: { A: item("A") } });
+ await playingAt("/media/A", 100);
+ const more = screen.getByLabelText("plex.player.more");
+ fireEvent.click(more);
+ fireEvent.click(await menuItem("plex.player.settings"));
+ const panel = await screen.findByRole("dialog", { name: "plex.player.settings" });
+ // Into the panel on open…
+ await waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
+ fireEvent.keyDown(document.activeElement!, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ // …and back on the ⋯ when it goes, not on .
+ await waitFor(() => expect(document.activeElement).toBe(more));
+ });
+
it("Next shows only when there is a next item", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx
index 44dbb8b2..650282ed 100644
--- a/frontend/src/components/PlexPlayer.session.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx
@@ -261,9 +261,9 @@ describe("the subtitle poll and notice", () => {
await screen.findByText("Title A");
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
const off = await screen.findByText("plex.player.subOff");
- expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
- const pressed = document.querySelectorAll('[aria-pressed="true"]');
- expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
+ expect(off.closest('[role="menuitemradio"]')?.getAttribute("aria-checked")).toBe("false");
+ const checked = document.querySelectorAll('[role="menuitemradio"][aria-checked="true"]');
+ expect([...checked].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
});
});
diff --git a/frontend/src/components/PlexPlayer.tabtrap.dom.test.tsx b/frontend/src/components/PlexPlayer.tabtrap.dom.test.tsx
index 3311cfac..e4dabf25 100644
--- a/frontend/src/components/PlexPlayer.tabtrap.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.tabtrap.dom.test.tsx
@@ -8,7 +8,7 @@
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
// too. Its own file because `layerStack` is module state and these tests push into it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
-import { render, cleanup, waitFor, act } from "@testing-library/react";
+import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -116,6 +116,17 @@ const press = (key: string, shiftKey = false) =>
);
});
+// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
+// measured it (never, under jsdom), and a hidden element's accessible name is empty.
+const menuItem = (label: string) =>
+ waitFor(() => {
+ const row = screen
+ .queryAllByRole("menuitem", { hidden: true })
+ .find((b) => b.textContent === label);
+ expect(row).toBeDefined();
+ return row!;
+ });
+
describe("PlexPlayer · Tab stays inside the player", () => {
it("pulls the focus back in when it is on the page behind", async () => {
await mount();
@@ -160,9 +171,10 @@ describe("PlexPlayer · Tab stays inside the player", () => {
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
expect(more).toBeDefined();
act(() => more!.click());
- const settings = buttons().find((b) => b.textContent === "plex.player.settings");
- expect(settings).toBeDefined();
- act(() => settings!.click());
+ // The ⋯ is a menu on the popover engine: it portals out of the player and traps for itself.
+ const settings = await menuItem("plex.player.settings");
+ act(() => settings.click());
+ await screen.findByRole("dialog", { name: "plex.player.settings" });
const items = buttons();
// The panel really opened — the trap now has to span its controls too.
expect(items.length).toBeGreaterThan(before + 3);
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 4a95f0a5..d759114d 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -82,7 +82,8 @@ import {
} from "../lib/layerStack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
-import { Pressable } from "./ui/Pressable";
+import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
+import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format";
@@ -104,6 +105,10 @@ const RECLAIM_WINDOW_MS = 120_000;
const BACK_TO_PREV_S = 3;
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
+// The player's menus sit on the always-dark media chrome, under white text, in every theme.
+const PLAYER_MENU =
+ "rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
+const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300;
@@ -236,7 +241,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
const [moreOpen, setMoreOpen] = useState(false);
- const moreRef = useRef(null);
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
@@ -244,9 +248,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const audioRef = useRef(null);
const menuOpenRef = useRef(false);
const menuRef = useRef(null);
- const menuTriggerRef = useRef(null);
- const tracksRef = useRef(null);
- const tracksTriggerRef = useRef(null);
+ const menuTriggerRef = useRef(null);
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const {
@@ -265,21 +267,40 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
onPointerEnter: () => holdOnHover(true),
onPointerLeave: () => holdOnHover(false),
};
- // Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
+ // The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
+ // It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
+ // what closes it (below).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
- useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
- useDismiss(moreOpen, () => setMoreOpen(false), [moreRef, menuTriggerRef]);
- // The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
- // Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
- // and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
- const tracksFade = useScrollFade();
- const tracksFadeRef = tracksFade.ref;
- const setTracksNode = useCallback(
- (node: HTMLElement | null) => {
- tracksRef.current = node as HTMLDivElement | null;
- tracksFadeRef(node);
+ // The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
+ // Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
+ // hand-built s — no roles, no arrows, and Escape left the focus on . The engine portals
+ // them into the fullscreen element when there is one, so they show in fullscreen too.
+ const morePop = useAnchoredPopover({
+ open: moreOpen,
+ // The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it.
+ onOpenChange: (open) => {
+ if (open && menuOpen) setMenuOpen(false);
+ else setMoreOpen(open);
},
- [tracksFadeRef],
+ placement: "top-end",
+ offset: 8,
+ });
+ const tracksPop = useAnchoredPopover({
+ open: tracksOpen,
+ onOpenChange: setTracksOpen,
+ placement: "top-end",
+ offset: 8,
+ fitHeight: true,
+ });
+ const tracksFade = useScrollFade();
+ // The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
+ const setMoreReference = morePop.refs.setReference;
+ const moreTriggerRef = useCallback(
+ (node: HTMLButtonElement | null) => {
+ menuTriggerRef.current = node;
+ setMoreReference(node);
+ },
+ [setMoreReference],
);
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
@@ -1593,74 +1614,89 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
- {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
+ {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
+ a track keeps it open: the change is seen where it was made, and the other group is
+ one arrow away. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
-
+ <>
setTracksOpen((o) => !o)}
+ ref={tracksPop.refs.setReference}
+ {...tracksPop.getReferenceProps()}
aria-expanded={tracksOpen}
label={t("plex.player.tracks")}
>
- {tracksOpen && (
+
+ {/* Portaled, but a React child of the player: a click or a wheel here must not
+ reach the stage's play-toggle and volume handlers. */}
e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
- className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
+ style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
+ className="overflow-auto no-scrollbar p-2"
>
{detail.audio_streams.length > 1 && (
- <>
+
{t("plex.player.audio")}
{detail.audio_streams.map((a) => (
-
changeAudio(a.ord)}
- aria-pressed={(audioOrd ?? 0) === a.ord}
- className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
+ tone="media"
+ role="menuitemradio"
+ checked={(audioOrd ?? 0) === a.ord}
+ label={a.label}
+ onSelect={() => changeAudio(a.ord)}
+ className={`${PLAYER_ROW} truncate ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
-
+
))}
- >
+
)}
{textSubs.length > 0 && (
- <>
+
{t("plex.player.subtitles")}
-
changeSubtitle(null)}
- aria-pressed={subOrd == null}
- className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
- subOrd == null ? "text-accent" : "text-white"
- }`}
+ changeSubtitle(null)}
+ className={`${PLAYER_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
{t("plex.player.subOff")}
-
- {textSubs.map((s) => (
-
changeSubtitle(s.ord)}
- aria-pressed={subOrd === s.ord}
- className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
- subOrd === s.ord ? "text-accent" : "text-white"
+
+ {textSubs.map((sub) => (
+ changeSubtitle(sub.ord)}
+ className={`${PLAYER_ROW} truncate ${
+ subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
- {s.label}
-
+ {sub.label}
+
))}
- >
+
)}
- )}
-
+
+ >
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
@@ -1670,40 +1706,39 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
variant="overlay"
size="player"
round
- ref={menuTriggerRef}
- onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
+ ref={moreTriggerRef}
+ {...morePop.getReferenceProps()}
aria-expanded={moreOpen || menuOpen}
label={t("plex.player.more")}
>
- {moreOpen && (
-
e.stopPropagation()}
- className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 flex w-60 flex-col rounded-xl p-1.5 text-sm text-white shadow-2xl"
- >
+
+ e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
-
{
- setMoreOpen(false);
- // Back on the ⋯ before the tool opens: its row is about to unmount, and a
- // dialog opened from here returns the focus to where it found it.
- menuTriggerRef.current?.focus();
- it.run();
- }}
- className="touch-target flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left hover:bg-white/10"
+ tone="media"
+ label={it.label}
+ // Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
+ // opened from here returns the focus to where it found it.
+ onSelect={() => closeThenAct(() => setMoreOpen(false), it.run)}
+ className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
>
{it.icon}
{it.label}
-
+
))}
- )}
+
{menuOpen && (
` lied
// about that and 18's types let it.
menuRef: RefObject;
+ /** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
+ * inside — by Escape, Back or the ⋯ itself — instead of falling to (E28 review D3). */
+ returnFocusTo: RefObject;
settingsTabs: { key: SettingsTab; label: string }[];
activeTab: SettingsTab;
setSettingsTab: (tab: SettingsTab) => void;
@@ -28,6 +31,7 @@ export function PlexPlayerSettings(props: {
}) {
const {
menuRef,
+ returnFocusTo,
settingsTabs,
activeTab,
setSettingsTab,
@@ -37,9 +41,23 @@ export function PlexPlayerSettings(props: {
now,
} = props;
const { t } = useTranslation();
+ // Into the panel on open (its first tab), and back to the ⋯ on close. The cleanup runs while the
+ // panel is still in the DOM, so "the focus was inside" is read before its removal drops it to
+ // ; focus the reader moved elsewhere (a click on the video) stays where they put it.
+ useLayoutEffect(() => {
+ const panel = menuRef.current;
+ const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
+ panel?.querySelector("button")?.focus({ preventScroll: true });
+ return () => {
+ if (panel?.contains(document.activeElement))
+ queueMicrotask(() => back?.focus({ preventScroll: true }));
+ };
+ }, [menuRef, returnFocusTo]);
return (
e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx
index ca04c75b..f9296b5e 100644
--- a/frontend/src/components/ui/AnchoredPopover.tsx
+++ b/frontend/src/components/ui/AnchoredPopover.tsx
@@ -104,6 +104,10 @@ const HIGHLIGHT_SELECTED =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
const HIGHLIGHT_SELECTED_RING_ONLY =
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
+// On the always-dark media chrome (a player's menus, `bg-media-chrome` under white text) the theme's
+// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
+const MEDIA_HIGHLIGHT =
+ "data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
export function PopoverItem({
children,
@@ -117,6 +121,7 @@ export function PopoverItem({
elementRef,
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
label,
+ tone,
...rest
}: {
children: ReactNode;
@@ -134,6 +139,8 @@ export function PopoverItem({
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
elementRef?: (node: HTMLButtonElement | null) => void;
label?: string;
+ /** `media`: the row sits on the always-dark media chrome (a player's menu), not a theme surface. */
+ tone?: "media";
} & Omit
, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
const virtual = ctx?.virtual ?? false;
@@ -212,11 +219,13 @@ export function PopoverItem({
disabled={disabled}
// touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3).
className={`outline-hidden touch-target ${
- checked || selected
- ? keepSelectedPaint
- ? HIGHLIGHT_SELECTED_RING_ONLY
- : HIGHLIGHT_SELECTED
- : ROVING_HIGHLIGHT
+ tone === "media"
+ ? MEDIA_HIGHLIGHT
+ : checked || selected
+ ? keepSelectedPaint
+ ? HIGHLIGHT_SELECTED_RING_ONLY
+ : HIGHLIGHT_SELECTED
+ : ROVING_HIGHLIGHT
}${className ? ` ${className}` : ""}`}
>
{children}
diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts
index 04c4d3ea..a8537dd6 100644
--- a/frontend/src/components/ui/pressableSelectable.contract.test.ts
+++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts
@@ -27,7 +27,6 @@ const PLAIN: Record = {
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
- "PlexPlayer.tsx": [4, "track picker rows and the ⋯ menu's rows"],
"PlexPlayerSettings.tsx": [2, "setting options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
diff --git a/frontend/src/lib/zLayer.contract.test.ts b/frontend/src/lib/zLayer.contract.test.ts
index 6f0c2f92..c57ef586 100644
--- a/frontend/src/lib/zLayer.contract.test.ts
+++ b/frontend/src/lib/zLayer.contract.test.ts
@@ -46,8 +46,13 @@ describe("the Plex player's layers", () => {
const popovers = classes(
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
);
- expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel
+ expect(popovers).toHaveLength(1); // the settings panel, which renders inside the player
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
+ // The tracks list and the ⋯ menu are on the popover engine (E28 review D3), which lifts them to
+ // z-popover — above the player's own z-overlay — and into the fullscreen element.
+ expect([...src.matchAll(/]*?className=\{`\$\{PLAYER_MENU\}/g)]).toHaveLength(
+ 2,
+ );
});
it("keeps the notices a level below", () => {