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:
@@ -551,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
}) as unknown as MediaQueryList,
|
}) 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(() => {
|
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 () => {
|
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();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
@@ -813,13 +813,18 @@ export default function PlayerModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
<span className="h-4 w-px bg-white/20" />
|
||||||
<IconButton
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||||
variant="overlay"
|
the window-filling view is the biggest, and YouTube's own button opens the
|
||||||
onClick={toggleTrueFullscreen}
|
system player. */}
|
||||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
{canFullscreenElements() && (
|
||||||
>
|
<IconButton
|
||||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
variant="overlay"
|
||||||
</IconButton>
|
onClick={toggleTrueFullscreen}
|
||||||
|
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -891,3 +891,33 @@ describe("the page under the player", () => {
|
|||||||
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
|
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";
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
beforeAll(() => {
|
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 {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import {
|
|||||||
usePlexTracks,
|
usePlexTracks,
|
||||||
useWallClock,
|
useWallClock,
|
||||||
} from "../lib/plexPlayerHooks";
|
} from "../lib/plexPlayerHooks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||||||
import {
|
import {
|
||||||
dispatch,
|
dispatch,
|
||||||
isActivationTarget,
|
isActivationTarget,
|
||||||
@@ -1632,9 +1632,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
|
||||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
did nothing there, and the windowed player already fills the window. */}
|
||||||
</Ctrl>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
// The browser's Fullscreen API, WebKit included.
|
// 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
|
// `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
|
// 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
|
// 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;
|
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
|
/** 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. */
|
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
|
||||||
export function requestFullscreen(el: HTMLElement): void {
|
export function requestFullscreen(el: HTMLElement): void {
|
||||||
const e = el as WebkitElement;
|
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?.();
|
else e.webkitRequestFullscreen?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user