diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
index 2d4b641b..37b6b6d8 100644
--- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
@@ -253,6 +253,48 @@ describe("keyboard focus in the bands", () => {
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
});
+
+ it("but not over the whole film: 10 s after the last key they fade, and a key brings them back", async () => {
+ mount({ details: { A: item("A") } });
+ await playingAt("/media/A", 100);
+ vi.useFakeTimers();
+ video()._paused = true;
+ act(() => void video().play());
+ const vol = screen.getByLabelText("plex.player.volume");
+ fireEvent.keyDown(document.body, { key: "Tab" });
+ act(() => vol.focus());
+ fireEvent.keyDown(vol, { key: "ArrowUp" });
+ act(() => vi.advanceTimersByTime(9000));
+ expect(bandHidden()).toBe(false);
+ act(() => vi.advanceTimersByTime(1500));
+ expect(bandHidden()).toBe(true);
+ expect(document.activeElement).toBe(vol);
+ fireEvent.keyDown(vol, { key: "ArrowUp" });
+ expect(bandHidden()).toBe(false);
+ });
+
+ it("a tap leaving a tapped button for the picture shows the hidden bands", async () => {
+ mount({ details: { A: item("A") } });
+ await playingAt("/media/A", 100);
+ vi.useFakeTimers();
+ video()._paused = true;
+ act(() => void video().play());
+ const btn = screen.getByLabelText("plex.player.mute");
+ // A finger's focus is not :focus-visible (jsdom calls every focus visible), so it holds nothing.
+ const matches = Element.prototype.matches;
+ vi.spyOn(Element.prototype, "matches").mockImplementation(function (
+ this: Element,
+ sel: string,
+ ) {
+ return sel === ":focus-visible" ? false : matches.call(this, sel);
+ });
+ act(() => btn.focus());
+ act(() => vi.advanceTimersByTime(3000));
+ expect(bandHidden()).toBe(true);
+ act(() => btn.blur()); // the tap on the picture takes the focus first…
+ pressPicture("touch"); // …then toggles
+ expect(bandHidden()).toBe(false);
+ });
});
describe("a double-tap on a side third skips 10 s (C5)", () => {
@@ -351,6 +393,34 @@ describe("a collection sibling in the info overlay", () => {
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
});
+
+ it(
+ "the film itself in its own collection restarts in place, and the clock runs on",
+ { timeout: 10000 },
+ async () => {
+ const self = withSibling("A");
+ self.collections![0]!.items[0] = {
+ ...self.collections![0]!.items[0]!,
+ id: "A",
+ title: "Title A",
+ };
+ const { session } = mount({ details: { A: self } });
+ await playingAt("/media/A", 100);
+ const asked = session.mock.calls.length;
+ fireEvent.click(screen.getByLabelText("plex.player.more"));
+ fireEvent.click(await menuItem("plex.player.infoBtn"));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }),
+ );
+ await waitFor(() => expect(video()._t).toBe(0));
+ expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
+ expect(video().paused).toBe(false);
+ const v = video();
+ v._t = 42;
+ act(() => void v.dispatchEvent(new Event("timeupdate")));
+ expect(screen.getByText(/^0:42 \//)).toBeTruthy();
+ },
+ );
});
describe("the C5 row", () => {
@@ -395,6 +465,42 @@ describe("the C5 row", () => {
// 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("↑/↓ on the closed ⋯ and tracks buttons are the volume, not an open menu", async () => {
+ const two = item("A");
+ two.audio_streams = [
+ ...two.audio_streams,
+ { ord: 1, label: "Magyar", language: "hu", default: false },
+ ] as PlexItemDetail["audio_streams"];
+ mount({ details: { A: two } });
+ await playingAt("/media/A", 100);
+ for (const label of ["plex.player.more", "plex.player.tracks"]) {
+ const trigger = screen.getByLabelText(label);
+ act(() => trigger.focus());
+ const before = video().volume;
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+ expect(screen.queryByRole("menu")).toBeNull();
+ expect(video().volume).toBeLessThan(before);
+ }
+ });
+
+ it("marks the chosen track with the tick of a one-of-several menu", async () => {
+ const two = item("A");
+ two.audio_streams = [
+ ...two.audio_streams,
+ { ord: 1, label: "Magyar", language: "hu", default: false },
+ ] as PlexItemDetail["audio_streams"];
+ mount({ details: { A: two } });
+ await playingAt("/media/A", 100);
+ fireEvent.click(screen.getByLabelText("plex.player.tracks"));
+ const rows = await screen.findAllByRole("menuitemradio", { hidden: true });
+ const ticked = rows.map((r) => {
+ const tick = r.querySelector("svg");
+ return tick != null && !tick.getAttribute("class")!.includes("opacity-0");
+ });
+ expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
+ expect(ticked).toEqual([true, false]);
+ });
+
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);
@@ -449,6 +555,18 @@ describe("the C5 row", () => {
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next"));
});
+ it("Next leaving under the focus (onto the last item) hands the focus to its neighbour", async () => {
+ mount({ details: { A: item("A", 0, "B"), B: item("B") } });
+ await playingAt("/media/A", 100);
+ const next = screen.getByLabelText("plex.player.next");
+ act(() => next.focus());
+ fireEvent.click(next);
+ await waitFor(() => expect(screen.queryByLabelText("plex.player.next")).toBeNull());
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByLabelText("plex.player.mute")),
+ );
+ });
+
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.tsx b/frontend/src/components/PlexPlayer.tsx
index 63413c58..1acf94aa 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
+import { useFocusKeeper } from "../lib/useFocusKeeper";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
@@ -82,6 +83,7 @@ import {
} from "../lib/layerStack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
+import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls";
@@ -109,6 +111,10 @@ const BACK_TO_PREV_S = 3;
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";
+// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
+// the highlight's white text cannot wash out the way it did the accent-coloured label (round 2, P6).
+const TRACK_ROW =
+ "flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300;
@@ -149,6 +155,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// can't start a detached
@@ -1714,9 +1736,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
- className={`${PLAYER_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
+ className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
- {t("plex.player.subOff")}
+
+ {t("plex.player.subOff")}
{textSubs.map((sub) => (
changeSubtitle(sub.ord)}
- className={`${PLAYER_ROW} truncate ${
+ className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
- {sub.label}
+
+ {sub.label}
))}
diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts
index 9a729da8..909bcae8 100644
--- a/frontend/src/components/ui/anchoredPopoverHooks.ts
+++ b/frontend/src/components/ui/anchoredPopoverHooks.ts
@@ -72,6 +72,11 @@ export interface AnchoredPopoverOptions {
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
* This is also what a native context menu does. */
dismissOnScroll?: boolean;
+ /** Menu/listbox only: ↑/↓ on the CLOSED trigger open the panel (the APG menu-button default, on).
+ * Turn it off where those keys already mean something on that trigger — a media player's row,
+ * where ↑/↓ are the volume from anywhere, and a trigger that swallowed them killed it (E28 review
+ * round 2, P2). Enter/Space/click still open it, and the arrows still rove once it is open. */
+ openOnArrowKey?: boolean;
}
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
@@ -199,6 +204,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
setHoverIndex(null);
},
selectedIndex: opts.selectedIndex ?? null,
+ openOnArrowKeyDown: opts.openOnArrowKey ?? true,
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index 7655c5e1..45f02636 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -73,12 +73,17 @@ export function useFullscreen(wrapRef: RefObject): {
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
// and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it
// starts a fresh 3 s. `holdOnFocus` is the same for a KEYBOARD focus in the chrome (E28 review E6): the
-// bar must not fade from under the control a key user is on.
+// bar must not fade from under the control a key user is on — but only for a while after the last
+// key (FOCUS_HOLD_MS). The focus never leaves the bands on its own (Escape hands it back to the ⋯), so
+// a hold without an end kept them over the whole film (round 2, P4); any key wakes them again.
//
// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the
// bar stayed up until the first tap however long the video played — and a tap on the picture only
// shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip
// takes back the show/hide its first tap did.
+const HIDE_MS = 3000;
+const FOCUS_HOLD_MS = 10_000;
+
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject;
menuOpenRef: RefObject;
@@ -105,11 +110,17 @@ export function useControlsVisibility(opts: {
setVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
- hideTimer.current = window.setTimeout(() => {
- // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
- if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current && !focused.current)
+ const wokeAt = Date.now();
+ const arm = (ms: number) => {
+ hideTimer.current = window.setTimeout(() => {
+ // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
+ if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return;
+ const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
+ if (focused.current && held > 0) return arm(held);
setVisible(false);
- }, 3000);
+ }, ms);
+ };
+ arm(HIDE_MS);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback(
(visible: boolean) => {
@@ -130,8 +141,12 @@ export function useControlsVisibility(opts: {
);
const holdOnFocus = useCallback(
(on: boolean) => {
+ const was = focused.current;
focused.current = on;
- if (!on) wake();
+ // Only a hold that ENDS restarts the timer. A tap moving the focus out of the bands (a finger
+ // never holds: its focus is not :focus-visible) re-showed them on its way to the picture, whose
+ // own toggle then hid them again — the tap did nothing on Android (round 2, P3).
+ if (was && !on) wake();
},
[wake],
);