fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end

- The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in
  place instead of pausing it on a frozen 0:00.
- Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false).
- A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap
  leaving a tapped button no longer re-shows the bands its own toggle then hid.
- Next leaving under the focus hands it to its neighbour (the player keeps its focus).
- The chosen audio / subtitle track wears the MenuCheck tick.
This commit is contained in:
2026-09-27 19:42:53 +02:00
parent b7ce1d467c
commit cda626be10
4 changed files with 176 additions and 13 deletions
@@ -253,6 +253,48 @@ describe("keyboard focus in the bands", () => {
act(() => vi.advanceTimersByTime(3000)); act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true); 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)", () => { 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])); await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed 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", () => { describe("the C5 row", () => {
@@ -395,6 +465,42 @@ describe("the C5 row", () => {
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape // E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
// closed it with the focus left on <body>. It is a menu on the popover engine now. // closed it with the focus left on <body>. 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 () => { it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => {
mount({ details: { A: item("A") } }); mount({ details: { A: item("A") } });
await playingAt("/media/A", 100); await playingAt("/media/A", 100);
@@ -449,6 +555,18 @@ describe("the C5 row", () => {
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next")); 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 () => { it("Next shows only when there is a next item", async () => {
mount({ details: { A: item("A") } }); mount({ details: { A: item("A") } });
await playingAt("/media/A", 100); await playingAt("/media/A", 100);
+31 -7
View File
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n"; import { plexLang } from "../i18n";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useFocusKeeper } from "../lib/useFocusKeeper";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js"; import Hls from "hls.js";
import { import {
@@ -82,6 +83,7 @@ import {
} from "../lib/layerStack"; } from "../lib/layerStack";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls"; import { Slider } from "./ui/NativeControls";
@@ -109,6 +111,10 @@ const BACK_TO_PREV_S = 3;
const PLAYER_MENU = const PLAYER_MENU =
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl"; "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 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 TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300; const DOUBLE_TAP_MS = 300;
@@ -149,6 +155,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// can't start a detached <video> playing audio in the background. // can't start a detached <video> playing audio in the background.
const aliveRef = useRef(true); const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body> (round 2, P5). The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response // Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount is dropped — it used to // that lands after a newer call, an item change or the player's unmount is dropped — it used to
@@ -291,6 +301,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}, },
placement: "top-end", placement: "top-end",
offset: 8, offset: 8,
// ↑/↓ are the volume wherever the focus is in the player, the ⋯ included.
openOnArrowKey: false,
}); });
const tracksPop = useAnchoredPopover({ const tracksPop = useAnchoredPopover({
open: tracksOpen, open: tracksOpen,
@@ -298,6 +310,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
placement: "top-end", placement: "top-end",
offset: 8, offset: 8,
fitHeight: true, fitHeight: true,
openOnArrowKey: false,
}); });
const tracksFade = useScrollFade(); const tracksFade = useScrollFade();
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return. // The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
@@ -824,6 +837,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const go = useCallback( const go = useCallback(
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => { (otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => {
if (otherId && otherId === id) {
// The item already playing (the info overlay's collection strip lists the film itself):
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1).
if (fromZero) seekTo(0);
videoRef.current?.play().catch(() => {});
return;
}
if (otherId) { if (otherId) {
startAtZeroRef.current = fromZero ? otherId : null; startAtZeroRef.current = fromZero ? otherId : null;
// A played-through item settles as watched, with no checkpoint at the credits (see // A played-through item settles as watched, with no checkpoint at the credits (see
@@ -850,7 +871,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
setId(otherId); setId(otherId);
} }
}, },
[settleProgress, finishProgress], [id, seekTo, settleProgress, finishProgress],
); );
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours. // Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
@@ -1696,11 +1717,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={(audioOrd ?? 0) === a.ord} checked={(audioOrd ?? 0) === a.ord}
label={a.label} label={a.label}
onSelect={() => changeAudio(a.ord)} onSelect={() => changeAudio(a.ord)}
className={`${PLAYER_ROW} truncate ${ className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white" (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`} }`}
> >
{a.label} <MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{a.label}</span>
</PopoverItem> </PopoverItem>
))} ))}
</div> </div>
@@ -1714,9 +1736,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd == null} checked={subOrd == null}
label={t("plex.player.subOff")} label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)} 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")} <MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem> </PopoverItem>
{textSubs.map((sub) => ( {textSubs.map((sub) => (
<PopoverItem <PopoverItem
@@ -1726,11 +1749,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd === sub.ord} checked={subOrd === sub.ord}
label={sub.label} label={sub.label}
onSelect={() => changeSubtitle(sub.ord)} onSelect={() => changeSubtitle(sub.ord)}
className={`${PLAYER_ROW} truncate ${ className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white" subOrd === sub.ord ? "text-accent" : "text-white"
}`} }`}
> >
{sub.label} <MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{sub.label}</span>
</PopoverItem> </PopoverItem>
))} ))}
</div> </div>
@@ -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). * 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. */ * This is also what a native context menu does. */
dismissOnScroll?: boolean; 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) { export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
@@ -199,6 +204,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
setHoverIndex(null); setHoverIndex(null);
}, },
selectedIndex: opts.selectedIndex ?? null, selectedIndex: opts.selectedIndex ?? null,
openOnArrowKeyDown: opts.openOnArrowKey ?? true,
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads // Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a // 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 // pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
+21 -6
View File
@@ -73,12 +73,17 @@ export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube // `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 // 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 // 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 // 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 // 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 // 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. // takes back the show/hide its first tap did.
const HIDE_MS = 3000;
const FOCUS_HOLD_MS = 10_000;
export function useControlsVisibility(opts: { export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>; barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>; menuOpenRef: RefObject<boolean>;
@@ -105,11 +110,17 @@ export function useControlsVisibility(opts: {
setVisible(true); setVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current); if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => { const wokeAt = Date.now();
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. const arm = (ms: number) => {
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current && !focused.current) 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); setVisible(false);
}, 3000); }, ms);
};
arm(HIDE_MS);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]); }, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback( const show = useCallback(
(visible: boolean) => { (visible: boolean) => {
@@ -130,8 +141,12 @@ export function useControlsVisibility(opts: {
); );
const holdOnFocus = useCallback( const holdOnFocus = useCallback(
(on: boolean) => { (on: boolean) => {
const was = focused.current;
focused.current = on; 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], [wake],
); );