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.
This commit is contained in:
2026-10-08 01:16:30 +02:00
parent 7f17f488ab
commit 4e70f168b1
2 changed files with 50 additions and 9 deletions
@@ -308,6 +308,45 @@ describe("leaving an item that was played through", () => {
act(() => void el.dispatchEvent(new Event("ended"))); 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 () => { it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false }); prefs({ upNext: false });
const { qc, progress } = mount({ const { qc, progress } = mount({
+11 -9
View File
@@ -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 // 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 // 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 // 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 // the layers the viewer OPENED one per press, the one opened last first (each holds its own history
// BackClose below). The shortcuts sheet is not in it: a Modal above us, with its own Escape layer // entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
// and Back-to-close. // 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. // - 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(
@@ -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). // The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
const closeStats = useCallback(() => setStatsOpen(false), []); const closeStats = useCallback(() => setStatsOpen(false), []);
const closeAnyPanel = useCallback(() => setPanel("none"), []); 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).
@@ -1225,12 +1225,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
)} )}
</div> </div>
{/* Each layer over the video claims its OWN history entry while it is up, so the browser/mouse {/* Each layer the viewer opens over the video claims its OWN history entry while it is up, so
Back closes the one opened last and the next Back the one under it — one shared entry was the browser/mouse Back closes the one opened last and the next Back the one under it — one
spent on the first layer, and the second Back left the player with the rest still up. */} 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 && <BackClose onBack={closeStats} />} {statsOpen && <BackClose onBack={closeStats} />}
{panel !== "none" && <BackClose onBack={closeAnyPanel} />} {panel !== "none" && panel !== "upNext" && <BackClose onBack={closeAnyPanel} />}
{skipProgress != null && <BackClose onBack={cancelSkip} />}
{/* Top bar */} {/* Top bar */}
<div <div