fix(player): the browser Back peels each layer over the Plex video, one press at a time

All the layers shared one history entry, so the first Back spent it on one layer and the second
left the player with the stats readout (or the countdown) still up. Each layer now holds its own
entry; Back closes the one opened last.
This commit is contained in:
2026-10-07 23:16:38 +02:00
parent 891dc628f3
commit 2aa3ac6e85
2 changed files with 49 additions and 21 deletions
@@ -3,8 +3,8 @@
// What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks // What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks
// menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces // menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces
// the other, so two can never draw on top of each other. The stats readout is not one of them — it // the other, so two can never draw on top of each other. The stats readout is not one of them — it
// stays up beside a panel — and every "back" (⌫, Escape, the mouse/browser Back) peels the panel // stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
// first, then the stats, and only then leaves the player. // leave the player; the mouse/browser Back peels them one per press too, the one opened last first.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react"; import { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -84,6 +84,16 @@ async function mount() {
return onClose; return onClose;
} }
/** The browser's Back, waited out to its popstate. */
const browserBack = () =>
act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
const press = (key: string) => const press = (key: string) =>
act(() => { act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
@@ -118,18 +128,31 @@ describe("PlexPlayer · one panel at a time", () => {
// history module's entry count is per module, and an earlier test's entries would be in it. // history module's entry count is per module, and an earlier test's entries would be in it.
await waitFor(() => expect(window.history.state?._ov).toBeDefined()); await waitFor(() => expect(window.history.state?._ov).toBeDefined());
await act(async () => { await browserBack();
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
await waitFor(() => expect(moreMenu()).toBeNull()); await waitFor(() => expect(moreMenu()).toBeNull());
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
it("the browser Back peels each layer that is up, one press at a time", async () => {
// One shared history entry for every layer: the first Back spent it on one layer, and the
// second left the player with the stats readout still up. Second in the file (see above): the
// first test leaves no entry behind.
window.history.pushState({ page: "player" }, ""); // the entry the player was opened on
const onClose = await mount();
press("d");
press("i");
await waitFor(() => expect(info()).not.toBeNull());
await browserBack();
await waitFor(() => expect(info()).toBeNull());
expect(stats()).not.toBeNull();
await browserBack();
await waitFor(() => expect(stats()).toBeNull());
expect(onClose).not.toHaveBeenCalled();
});
it("the info overlay replaces the tuning panel rather than drawing over it", async () => { it("the info overlay replaces the tuning panel rather than drawing over it", async () => {
await mount(); await mount();
await openSettings(); await openSettings();
+17 -12
View File
@@ -855,15 +855,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
closePanel("upNext"); closePanel("upNext");
} }
// The ONE Back cascade, for every way of saying "back": the on-screen arrow, ⌫, Escape, and the // The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
// mouse/browser Back (history popstate, through BackClose below). It peels off what is layered // Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
// over the video — the auto-skip countdown, then the open panel, then the stats readout — and only // panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
// then leaves the player. The shortcuts sheet is not in it: a Modal above us, with its own Escape // the same layers one per press, the one opened last first (each holds its own history entry, see
// layer and Back-to-close. // BackClose below). The shortcuts sheet is not in it: a Modal above us, with its own Escape layer
// and Back-to-close.
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown. // - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player. // - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
const back = useCallback( const back = useCallback(
(from: "arrow" | "key" | "history" | "escape") => { (from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current(); if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (panelRef.current !== "none") setPanel("none"); else if (panelRef.current !== "none") setPanel("none");
else if (statsOpenRef.current) setStatsOpen(false); else if (statsOpenRef.current) setStatsOpen(false);
@@ -871,7 +872,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}, },
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef], [onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef],
); );
const backFromHistory = useCallback(() => back("history"), [back]); // The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
const closeStats = useCallback(() => setStatsOpen(false), []);
const closeAnyPanel = useCallback(() => setPanel("none"), []);
const cancelSkip = useCallback(() => cancelAutoSkipRef.current(), [cancelAutoSkipRef]);
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step // Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
// episodes, so they are listed only inside a series). // episodes, so they are listed only inside a series).
@@ -1223,11 +1227,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
)} )}
</div> </div>
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse {/* Each layer over the video claims its OWN history entry while it is up, so the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */} Back closes the one opened last and the next Back the one under it — one shared entry was
{(panel !== "none" || statsOpen || skipProgress != null) && ( spent on the first layer, and the second Back left the player with the rest still up. */}
<BackClose onBack={backFromHistory} /> {statsOpen && <BackClose onBack={closeStats} />}
)} {panel !== "none" && <BackClose onBack={closeAnyPanel} />}
{skipProgress != null && <BackClose onBack={cancelSkip} />}
{/* Top bar */} {/* Top bar */}
<div <div