diff --git a/frontend/src/components/PlexPlayer.panels.dom.test.tsx b/frontend/src/components/PlexPlayer.panels.dom.test.tsx
index ba24e08a..0ab73f67 100644
--- a/frontend/src/components/PlexPlayer.panels.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.panels.dom.test.tsx
@@ -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();
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index f5a8af19..c14ec731 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -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 }
)}
- {/* 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) && (
-