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) && ( - - )} + {/* 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 && } + {panel !== "none" && } + {skipProgress != null && } {/* Top bar */}