From 5249c41edca633b0111f8ed0c875109489fc7137 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:50:51 +0200 Subject: [PATCH] fix(player): a fullscreen button the browser cannot honour is not shown Brave on iPadOS switches element fullscreen off, patches requestFullscreen with a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button and the YouTube docked bar's did nothing. Support is now read off Element.prototype and the buttons are left out without it (owner). A stand-in that returns a boolean no longer throws. A test's bare matchMedia stub no longer outlives its describe. --- .../src/components/PlayerModal.dom.test.tsx | 43 ++++++++++++++++++- frontend/src/components/PlayerModal.tsx | 21 +++++---- .../PlexPlayer.controls.dom.test.tsx | 30 +++++++++++++ .../PlexPlayer.tabtrap.dom.test.tsx | 6 +++ frontend/src/components/PlexPlayer.tsx | 12 ++++-- frontend/src/lib/fullscreen.ts | 19 +++++++- 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 7f7c661b..5ae0a9b9 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -551,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => { removeEventListener: () => {}, }) as unknown as MediaQueryList, ); + // jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or + // every later test's media query would read that bare stub. + let added = false; beforeEach(() => { - window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia; + if (typeof window.matchMedia === "function") return; + window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia; + added = true; + }); + afterEach(() => { + // The spy goes first: restoring it later would put the bare stub back. + vi.restoreAllMocks(); + if (added) delete (window as { matchMedia?: unknown }).matchMedia; + added = false; }); it("puts the details beside the video, the description open, and sizes the card for both", async () => { @@ -657,3 +668,33 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(screen.queryByTestId("player-description")).toBeNull(); }); }); + +// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's +// true-fullscreen button did nothing there. The window-filling view is then the biggest one. +describe("PlayerModal — the docked bar's fullscreen", () => { + const maximised = (api_: boolean) => { + if (api_) + Object.defineProperty(Element.prototype, "requestFullscreen", { + configurable: true, + value: () => Promise.resolve(), + }); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + fireEvent.click(screen.getByLabelText("player.fullscreen")); + return screen.getAllByLabelText("player.fullscreen").length; + }; + afterEach(() => { + delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen; + }); + + it("is offered where the browser can fullscreen an element", () => { + expect(maximised(true)).toBe(2); + }); + + it("is left out where it cannot", () => { + expect(maximised(false)).toBe(1); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index ae4f2b94..9998b6ab 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -57,7 +57,7 @@ import { formatCompact, relativeTime, } from "../lib/format"; -import { fullscreenElement } from "../lib/fullscreen"; +import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import ChannelNameLink from "./ChannelNameLink"; @@ -813,13 +813,18 @@ export default function PlayerModal({ )} - - {isFullscreen ? : } - + {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there + the window-filling view is the biggest, and YouTube's own button opens the + system player. */} + {canFullscreenElements() && ( + + {isFullscreen ? : } + + )}