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.
This commit is contained in:
2026-10-08 02:50:51 +02:00
parent c3664826fe
commit 5249c41edc
6 changed files with 116 additions and 15 deletions
@@ -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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} 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);
});
});
+13 -8
View File
@@ -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({
</Button>
)}
<span className="h-4 w-px bg-white/20" />
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
{/* 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() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
@@ -891,3 +891,33 @@ describe("the page under the player", () => {
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
// nothing there. It is shown only where the browser has the API on Element.prototype.
describe("the fullscreen button", () => {
const withApi = (on: boolean) => {
if (on)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
};
afterEach(() => withApi(false));
it("is there where the browser can fullscreen an element", async () => {
withApi(true);
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
});
it("is left out where it cannot, whatever fullscreenEnabled claims", async () => {
withApi(false);
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
});
});
@@ -21,6 +21,12 @@ import { api, type PlexItemDetail } from "../lib/api";
import { pushLayer, removeLayer } from "../lib/layerStack";
beforeAll(() => {
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
class Stub {
observe() {}
unobserve() {}
+8 -4
View File
@@ -70,7 +70,7 @@ import {
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
isActivationTarget,
@@ -1632,9 +1632,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
/>
)}
</div>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
did nothing there, and the windowed player already fills the window. */}
{canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
</div>
+17 -2
View File
@@ -8,7 +8,7 @@ import {
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// Safari and iPadOS before 16.4 ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
@@ -29,11 +29,26 @@ export function fullscreenElement(): Element | null {
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Can this browser put a plain element in fullscreen at all? Read off `Element.prototype`, never
* `document.fullscreenEnabled`: Brave on iPadOS switches element fullscreen off in its web view, then
* patches `HTMLElement.prototype.requestFullscreen` with a stand-in that only fullscreens a <video>
* and forces `fullscreenEnabled` to true — both claim a support that is not there, and the players'
* fullscreen buttons did nothing. A button that cannot work is not shown (owner). */
export function canFullscreenElements(): boolean {
if (typeof Element === "undefined") return false;
const proto = Element.prototype as Partial<WebkitElement>;
return (
typeof proto.requestFullscreen === "function" ||
typeof proto.webkitRequestFullscreen === "function"
);
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
// Wrapped: a patched stand-in (Brave's, above) may return a boolean, not a promise.
if (e.requestFullscreen) void Promise.resolve(e.requestFullscreen()).catch(() => {});
else e.webkitRequestFullscreen?.();
}