diff --git a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
index ac0771fa..07d5b65c 100644
--- a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
+++ b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
@@ -106,3 +106,39 @@ describe("a dialog whose own content goes fullscreen", () => {
expect(video.closest("body > div")).toBe(host);
});
});
+
+describe("Escape while the dialog's own content is fullscreen", () => {
+ const escape = () =>
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+
+ it("only leaves fullscreen (the browser's part) — the dialog stays open", () => {
+ // The browser spends that Escape on leaving fullscreen and still hands it to the page, with the
+ // video still the fullscreen element: the dialog closed with it, and the viewer lost the player.
+ const onClose = vi.fn();
+ render(
+
+
+ ,
+ );
+ setFullscreen(screen.getByLabelText("film"));
+ escape();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("still closes a dialog opened over something else that is fullscreen", () => {
+ const stage = document.createElement("div");
+ document.body.appendChild(stage);
+ setFullscreen(stage);
+ const onClose = vi.fn();
+ render(
+
+ form
+ ,
+ );
+ escape();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ stage.remove();
+ });
+});
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
index d3babaf4..9f65bfec 100644
--- a/frontend/src/components/ui/Modal.tsx
+++ b/frontend/src/components/ui/Modal.tsx
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history";
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack";
-import { useFullscreenPortalHost } from "../../lib/fullscreen";
+import { fullscreenElement, useFullscreenPortalHost } from "../../lib/fullscreen";
import { firstFieldIn, focusablesIn, trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
@@ -151,7 +151,11 @@ export default function Modal({
const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) {
e.stopPropagation();
- close();
+ // The browser spends an Escape on leaving fullscreen and still hands it to the page, the
+ // fullscreen element still set. When that element is the dialog's own content (the library
+ // player's