From 4e70f168b106757348160250ead0127ec40c04f9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 01:16:30 +0200 Subject: [PATCH] fix(player): a layer that opens by itself takes no history entry The auto-skip countdown and the up-next card pushed an entry without a user gesture (Chrome's Back button skips those), and one pushed under the open shortcuts sheet sat above it, so Back closed the hidden layer. --- .../PlexPlayer.session.dom.test.tsx | 39 +++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 20 +++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index 7bfe4579..e0d48c63 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -308,6 +308,45 @@ describe("leaving an item that was played through", () => { act(() => void el.dispatchEvent(new Event("ended"))); }; + // A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history + // entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an + // open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog. + const overlayPushes = (push: { mock: { calls: unknown[][] } }) => + push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null); + + it("gives the auto-skip countdown no history entry", async () => { + prefs({ autoSkipIntro: true, autoSkipDelay: 5 }); + const withIntro = { + ...item("A"), + markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }], + } as unknown as PlexItemDetail; + mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } }); + await playingAt("/media/A", 50); + await tick(); + const push = vi.spyOn(window.history, "pushState"); + await playingAt("/media/A", 110); // inside the intro: the countdown starts + await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy()); + await tick(); + expect(overlayPushes(push)).toEqual([]); + }); + + it("gives the up-next card no history entry", async () => { + prefs({ upNext: true }); + mount({ + details: { A: item("A"), B: item("B") }, + sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) }, + queue: ["A", "B"], + }); + vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + await playingAt("/media/A", 2995); + await tick(); + const push = vi.spyOn(window.history, "pushState"); + endMedia(); + await screen.findByText("Title B"); // the card names the next item + await tick(); + expect(overlayPushes(push)).toEqual([]); + }); + it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => { prefs({ upNext: false }); const { qc, progress } = mount({ diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 575332ea..9ba2dcf3 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -856,9 +856,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // 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 layers the viewer OPENED one per press, the one opened last first (each holds its own history + // entry, see BackClose below); the ones that open by themselves have none, so Back leaves past + // them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and + // Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back). // - 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( @@ -873,7 +874,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // 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). @@ -1225,12 +1225,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } )} - {/* 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. */} + {/* Each layer the viewer opens 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. The auto-skip countdown and the up-next card open BY THEMSELVES and claim none: + Chrome's Back button skips an entry pushed without a user gesture, and one pushed while the + shortcuts sheet was open sat above the sheet's, so Back closed the hidden layer. */} {statsOpen && } - {panel !== "none" && } - {skipProgress != null && } + {panel !== "none" && panel !== "upNext" && } {/* Top bar */}