diff --git a/extension/content/inject.js b/extension/content/inject.js index 3e6665f..8aa9379 100644 --- a/extension/content/inject.js +++ b/extension/content/inject.js @@ -3137,14 +3137,14 @@ function disableDirectFullscreenPopover(videoController) { wrapper.removeAttribute("popover"); } -function enableDirectFullscreenPopover(videoController) { +function enableFullscreenPopover(videoController, preferredMount) { if (!videoController || !videoController.video || !videoController.div) { return false; } var wrapper = videoController.div; if (typeof wrapper.showPopover !== "function") return false; - var normalMount = videoController.normalControllerMount; + var normalMount = preferredMount || videoController.normalControllerMount; var normalMountIsConnected = Boolean( normalMount && (normalMount.isConnected || @@ -3185,12 +3185,6 @@ function syncControllerFullscreenMount(videoController) { var fullscreenElement = getFullscreenElement(doc); var targetMount = videoController.normalControllerMount; - if (fullscreenElement === video) { - if (enableDirectFullscreenPopover(videoController)) return true; - } else { - disableDirectFullscreenPopover(videoController); - } - if ( fullscreenElement && (fullscreenElement === video || @@ -3203,6 +3197,18 @@ function syncControllerFullscreenMount(videoController) { } if (!targetMount) return false; + + if (fullscreenElement) { + // Fullscreen elements and popovers both participate in the browser's top + // layer. Showing Speeder's host after the player enters fullscreen keeps it + // above provider-owned surfaces even when the provider clips descendants or + // creates a new fullscreen stacking context (notably Firefox + YouTube). + // Browsers without the Popover API retain the player-local remount fallback. + if (enableFullscreenPopover(videoController, targetMount)) return true; + } else { + disableDirectFullscreenPopover(videoController); + } + return remountControllerHost(videoController, targetMount); } diff --git a/tests/inject-lifecycle.regression.test.js b/tests/inject-lifecycle.regression.test.js index d099ae0..4c31e3c 100644 --- a/tests/inject-lifecycle.regression.test.js +++ b/tests/inject-lifecycle.regression.test.js @@ -443,6 +443,52 @@ describe("inject.js media/controller lifecycle regressions", () => { controller.controllerHostCleanup(); }); + it("promotes an ancestor-fullscreen controller into the browser top layer", async () => { + bootInject(); + await settleLifecycle(); + + const fullscreenPlayer = document.createElement("div"); + const video = document.createElement("video"); + const wrapper = document.createElement("div"); + const rect = makeRect(0, 0, 1280, 720); + + fullscreenPlayer.append(video, wrapper); + document.body.appendChild(fullscreenPlayer); + [fullscreenPlayer, video].forEach((element) => { + setRect(element, rect); + setBoxMetrics(element, rect.width, rect.height); + }); + wrapper.showPopover = vi.fn(); + wrapper.hidePopover = vi.fn(); + + const controller = { + video, + div: wrapper, + normalControllerMount: fullscreenPlayer + }; + window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer); + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + value: fullscreenPlayer + }); + + expect(window.syncControllerFullscreenMount(controller)).toBe(true); + expect(wrapper.parentNode).toBe(fullscreenPlayer); + expect(wrapper.showPopover).toHaveBeenCalledOnce(); + expect(wrapper.getAttribute("popover")).toBe("manual"); + expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true); + + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + value: null + }); + window.syncControllerFullscreenMount(controller); + expect(wrapper.hidePopover).toHaveBeenCalledOnce(); + + wrapper.remove(); + controller.controllerHostCleanup(); + }); + it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => { bootInject(); await settleLifecycle();