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:
@@ -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 <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.
|
||||
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);
|
||||
|
||||
@@ -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 <video> playing audio in the background.
|
||||
const aliveRef = useRef(true);
|
||||
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
|
||||
// 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
|
||||
@@ -291,6 +301,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
},
|
||||
placement: "top-end",
|
||||
offset: 8,
|
||||
// ↑/↓ are the volume wherever the focus is in the player, the ⋯ included.
|
||||
openOnArrowKey: false,
|
||||
});
|
||||
const tracksPop = useAnchoredPopover({
|
||||
open: tracksOpen,
|
||||
@@ -298,6 +310,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
placement: "top-end",
|
||||
offset: 8,
|
||||
fitHeight: true,
|
||||
openOnArrowKey: false,
|
||||
});
|
||||
const tracksFade = useScrollFade();
|
||||
// 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(
|
||||
(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) {
|
||||
startAtZeroRef.current = fromZero ? otherId : null;
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[settleProgress, finishProgress],
|
||||
[id, seekTo, settleProgress, finishProgress],
|
||||
);
|
||||
|
||||
// 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}
|
||||
label={a.label}
|
||||
onSelect={() => changeAudio(a.ord)}
|
||||
className={`${PLAYER_ROW} truncate ${
|
||||
className={`${TRACK_ROW} ${
|
||||
(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>
|
||||
))}
|
||||
</div>
|
||||
@@ -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")}
|
||||
<MenuCheck kind="one" on={subOrd == null} />
|
||||
<span className="truncate">{t("plex.player.subOff")}</span>
|
||||
</PopoverItem>
|
||||
{textSubs.map((sub) => (
|
||||
<PopoverItem
|
||||
@@ -1726,11 +1749,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
checked={subOrd === sub.ord}
|
||||
label={sub.label}
|
||||
onSelect={() => changeSubtitle(sub.ord)}
|
||||
className={`${PLAYER_ROW} truncate ${
|
||||
className={`${TRACK_ROW} ${
|
||||
subOrd === sub.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{sub.label}
|
||||
<MenuCheck kind="one" on={subOrd === sub.ord} />
|
||||
<span className="truncate">{sub.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</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).
|
||||
* 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
|
||||
|
||||
@@ -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
|
||||
// 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<boolean>;
|
||||
menuOpenRef: RefObject<boolean>;
|
||||
@@ -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
|
||||
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 && !focused.current)
|
||||
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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user