From ce7ffaf5529109b58efee36e364ca2f3df524abe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 12:02:08 +0000 Subject: [PATCH] fix: add ios fullscreen shim for emulatorjs Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com> --- frontend/src/views/Player/EmulatorJS/Base.vue | 9 + frontend/src/views/Player/EmulatorJS/utils.ts | 195 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/frontend/src/views/Player/EmulatorJS/Base.vue b/frontend/src/views/Player/EmulatorJS/Base.vue index b80a56af5..af02f9317 100644 --- a/frontend/src/views/Player/EmulatorJS/Base.vue +++ b/frontend/src/views/Player/EmulatorJS/Base.vue @@ -20,6 +20,7 @@ import type { Events } from "@/types/emitter"; import { getSupportedEJSCores } from "@/utils"; import CacheDialog from "@/views/Player/EmulatorJS/CacheDialog.vue"; import Player from "@/views/Player/EmulatorJS/Player.vue"; +import { installIOSFullscreenShim } from "./utils"; const { t } = useI18n(); const { xs, mdAndUp, smAndDown } = useDisplay(); @@ -38,6 +39,7 @@ const selectedDisc = ref(null); const selectedCore = ref(null); const selectedFirmware = ref(null); const supportedCores = ref([]); +const removeIOSFullscreenShim = ref<(() => void) | null>(null); const gameRunning = ref(false); const fullScreenOnPlay = useLocalStorage("emulation.fullScreenOnPlay", true); @@ -58,6 +60,9 @@ const compatibleStates = computed( ); async function onPlay() { + removeIOSFullscreenShim.value?.(); + removeIOSFullscreenShim.value = installIOSFullscreenShim(); + if (rom.value && auth.scopes.includes("roms.user.write")) { romApi.updateUserRomProps({ romId: rom.value.id, @@ -102,6 +107,8 @@ async function onPlay() { playing.value = true; fullScreen.value = fullScreenOnPlay.value; } catch (err) { + removeIOSFullscreenShim.value?.(); + removeIOSFullscreenShim.value = null; console.error("[Play] Emulator load failure:", err); } } @@ -261,6 +268,8 @@ onMounted(async () => { onBeforeUnmount(async () => { window.EJS_emulator?.callEvent("exit"); + removeIOSFullscreenShim.value?.(); + removeIOSFullscreenShim.value = null; emitter?.off("saveSelected", selectSave); emitter?.off("stateSelected", selectState); }); diff --git a/frontend/src/views/Player/EmulatorJS/utils.ts b/frontend/src/views/Player/EmulatorJS/utils.ts index ffa8ccfff..09f7b9c67 100644 --- a/frontend/src/views/Player/EmulatorJS/utils.ts +++ b/frontend/src/views/Player/EmulatorJS/utils.ts @@ -145,6 +145,201 @@ export function loadEmulatorJSState(state: Uint8Array) { window.EJS_emulator.gameManager.loadState(state); } +type FullscreenCapableHTMLElement = typeof HTMLElement.prototype & { + webkitRequestFullscreen?: () => void; +}; + +const IOS_FULLSCREEN_NAV_SELECTOR = + ".v-app-bar, .v-bottom-navigation, .v-navigation-drawer"; + +function isIOSFullscreenShimRequired() { + return ( + /iP(ad|hone|od)/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) + ); +} + +function restoreProperty( + target: object, + property: PropertyKey, + descriptor?: PropertyDescriptor, +) { + if (descriptor) { + Object.defineProperty(target, property, descriptor); + return; + } + + Reflect.deleteProperty(target, property); +} + +export function installIOSFullscreenShim() { + if (!isIOSFullscreenShimRequired()) { + return () => {}; + } + + const htmlElementPrototype = + HTMLElement.prototype as FullscreenCapableHTMLElement; + const fullscreenEnabledDescriptor = Object.getOwnPropertyDescriptor( + document, + "fullscreenEnabled", + ); + const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor( + document, + "fullscreenElement", + ); + const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor( + document, + "exitFullscreen", + ); + const requestFullscreenDescriptor = Object.getOwnPropertyDescriptor( + htmlElementPrototype, + "requestFullscreen", + ); + const webkitRequestFullscreenDescriptor = Object.getOwnPropertyDescriptor( + htmlElementPrototype, + "webkitRequestFullscreen", + ); + const navDisplayMap = new Map< + HTMLElement, + { value: string; priority: string } + >(); + + let pseudoElement: HTMLElement | null = null; + let originalStyle = ""; + let hadStyleAttribute = false; + + const dispatchFullscreenChange = (element: HTMLElement | null) => { + document.dispatchEvent(new Event("fullscreenchange")); + element?.dispatchEvent(new Event("fullscreenchange")); + }; + + const hideNavigation = () => { + document + .querySelectorAll(IOS_FULLSCREEN_NAV_SELECTOR) + .forEach((element) => { + navDisplayMap.set(element, { + value: element.style.getPropertyValue("display"), + priority: element.style.getPropertyPriority("display"), + }); + element.style.setProperty("display", "none", "important"); + }); + }; + + const showNavigation = () => { + navDisplayMap.forEach((display, element) => { + if (display.value) { + element.style.setProperty("display", display.value, display.priority); + } else { + element.style.removeProperty("display"); + } + }); + navDisplayMap.clear(); + }; + + const setFullscreenElement = (element: HTMLElement | null) => { + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => element, + }); + }; + + const exitPseudoFullscreen = () => { + if (!pseudoElement) { + return Promise.resolve(); + } + + const currentElement = pseudoElement; + + if (hadStyleAttribute) { + currentElement.setAttribute("style", originalStyle); + } else { + currentElement.removeAttribute("style"); + } + + showNavigation(); + setFullscreenElement(null); + pseudoElement = null; + originalStyle = ""; + hadStyleAttribute = false; + dispatchFullscreenChange(currentElement); + + return Promise.resolve(); + }; + + const enterPseudoFullscreen = (element: HTMLElement) => { + if (pseudoElement === element) { + return Promise.resolve(); + } + + if (pseudoElement) { + void exitPseudoFullscreen(); + } + + pseudoElement = element; + hadStyleAttribute = element.hasAttribute("style"); + originalStyle = element.getAttribute("style") ?? ""; + element.style.cssText = + `${originalStyle}${originalStyle ? ";" : ""}` + + "position:fixed!important;top:0!important;left:0!important;" + + "width:100vw!important;height:100svh!important;z-index:99999!important;" + + "background:#000!important;"; + + hideNavigation(); + setFullscreenElement(element); + dispatchFullscreenChange(element); + + return Promise.resolve(); + }; + + Object.defineProperty(document, "fullscreenEnabled", { + configurable: true, + get: () => true, + }); + setFullscreenElement(null); + Object.defineProperty(document, "exitFullscreen", { + configurable: true, + value: () => exitPseudoFullscreen(), + writable: true, + }); + Object.defineProperty(htmlElementPrototype, "requestFullscreen", { + configurable: true, + value: function requestFullscreen(this: HTMLElement) { + return enterPseudoFullscreen(this); + }, + writable: true, + }); + + if ( + webkitRequestFullscreenDescriptor || + "webkitRequestFullscreen" in htmlElementPrototype + ) { + Object.defineProperty(htmlElementPrototype, "webkitRequestFullscreen", { + configurable: true, + value: function webkitRequestFullscreen(this: HTMLElement) { + void enterPseudoFullscreen(this); + }, + writable: true, + }); + } + + return () => { + void exitPseudoFullscreen(); + restoreProperty( + htmlElementPrototype, + "webkitRequestFullscreen", + webkitRequestFullscreenDescriptor, + ); + restoreProperty( + htmlElementPrototype, + "requestFullscreen", + requestFullscreenDescriptor, + ); + restoreProperty(document, "exitFullscreen", exitFullscreenDescriptor); + restoreProperty(document, "fullscreenElement", fullscreenElementDescriptor); + restoreProperty(document, "fullscreenEnabled", fullscreenEnabledDescriptor); + }; +} + export function createQuickLoadButton(): HTMLButtonElement { const button = document.createElement("button"); button.type = "button";