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
// 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
// stays up beside a panel — and every "back" (⌫, Escape, the mouse/browser Back) peels the panel
// first, then the stats, and only then leaves the player.
// stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
// 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 { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -84,6 +84,16 @@ async function mount() {
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) =>
act(() => {
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.
await waitFor(() => expect(window.history.state?._ov).toBeDefined());
await act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
await browserBack();
await waitFor(() => expect(moreMenu()).toBeNull());
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 () => {
await mount();
await openSettings();
+17 -12
View File
@@ -855,15 +855,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
closePanel("upNext");
}
// The ONE Back cascade, for every way of saying "back": the on-screen arrow, ⌫, Escape, and the
// mouse/browser Back (history popstate, through BackClose below). It peels off what is layered
// over the video — the auto-skip countdown, then the open panel, then the stats readout — and only
// then leaves the player. The shortcuts sheet is not in it: a Modal above us, with its own Escape
// layer and Back-to-close.
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
// panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
// the same layers one per press, the one opened last first (each holds its own history entry, see
// 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.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
const back = useCallback(
(from: "arrow" | "key" | "history" | "escape") => {
(from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (panelRef.current !== "none") setPanel("none");
else if (statsOpenRef.current) setStatsOpen(false);
@@ -871,7 +872,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
[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
// episodes, so they are listed only inside a series).
@@ -1223,11 +1227,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
)}
</div>
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(panel !== "none" || statsOpen || skipProgress != null) && (
<BackClose onBack={backFromHistory} />
)}
{/* Each layer over the video claims its OWN history entry while it is up, so the browser/mouse
Back closes the one opened last and the next Back the one under it — one shared entry was
spent on the first layer, and the second Back left the player with the rest still up. */}
{statsOpen && <BackClose onBack={closeStats} />}
{panel !== "none" && <BackClose onBack={closeAnyPanel} />}
{skipProgress != null && <BackClose onBack={cancelSkip} />}
{/* Top bar */}
<div