From 667672bba5679231e6862310510ae5642d0bcf9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 11:54:20 +0000 Subject: [PATCH 1/3] Initial plan 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 2/3] 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 b80a56af5a..af02f93173 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 ffa8ccfff9..09f7b9c679 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"; From 31e6d995a6cab466fa97f992be893aa1217a1980 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Mon, 25 May 2026 10:38:54 -0400 Subject: [PATCH 3/3] simplify --- frontend/src/views/Player/EmulatorJS/utils.ts | 232 ++++++------------ 1 file changed, 74 insertions(+), 158 deletions(-) diff --git a/frontend/src/views/Player/EmulatorJS/utils.ts b/frontend/src/views/Player/EmulatorJS/utils.ts index 09f7b9c679..5aba36074d 100644 --- a/frontend/src/views/Player/EmulatorJS/utils.ts +++ b/frontend/src/views/Player/EmulatorJS/utils.ts @@ -1,3 +1,4 @@ +import Bowser from "bowser"; import { type SaveSchema } from "@/__generated__"; import { type StateSchema } from "@/__generated__"; import saveApi from "@/services/api/save"; @@ -145,198 +146,113 @@ 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"; +const IOS_FULLSCREEN_STYLE = ` + [data-ios-fullscreen-active] { + position: fixed !important; + inset: 0 !important; + width: 100vw !important; + height: 100svh !important; + z-index: 99999 !important; + background: #000 !important; + } + [data-ios-fullscreen-hidden] { display: none !important; } +`; function isIOSFullscreenShimRequired() { + const osName = Bowser.getParser(navigator.userAgent).getOSName(true); return ( - /iP(ad|hone|od)/.test(navigator.userAgent) || + osName === "ios" || + // iPadOS 13+ reports as macOS with touch support, so fall back to that check. (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"); - } + const proto = HTMLElement.prototype; + const overrides: Array<{ + target: object; + key: PropertyKey; + prev?: PropertyDescriptor; + }> = []; + const override = ( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, + ) => { + overrides.push({ + target, + key, + prev: Object.getOwnPropertyDescriptor(target, key), }); - navDisplayMap.clear(); + Object.defineProperty(target, key, { configurable: true, ...descriptor }); }; - const setFullscreenElement = (element: HTMLElement | null) => { - Object.defineProperty(document, "fullscreenElement", { - configurable: true, - get: () => element, - }); - }; - - const exitPseudoFullscreen = () => { - if (!pseudoElement) { - return Promise.resolve(); - } + const styleEl = document.createElement("style"); + styleEl.textContent = IOS_FULLSCREEN_STYLE; + document.head.appendChild(styleEl); - const currentElement = pseudoElement; + let fullscreenElement: HTMLElement | null = null; - if (hadStyleAttribute) { - currentElement.setAttribute("style", originalStyle); - } else { - currentElement.removeAttribute("style"); - } + const dispatchChange = (target: HTMLElement) => { + document.dispatchEvent(new Event("fullscreenchange")); + target.dispatchEvent(new Event("fullscreenchange")); + }; - showNavigation(); - setFullscreenElement(null); - pseudoElement = null; - originalStyle = ""; - hadStyleAttribute = false; - dispatchFullscreenChange(currentElement); + const enter = (el: HTMLElement) => { + if (fullscreenElement === el) return Promise.resolve(); + if (fullscreenElement) void exit(); + el.setAttribute("data-ios-fullscreen-active", ""); + document + .querySelectorAll(IOS_FULLSCREEN_NAV_SELECTOR) + .forEach((nav) => nav.setAttribute("data-ios-fullscreen-hidden", "")); + fullscreenElement = el; + dispatchChange(el); 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); - + const exit = () => { + const el = fullscreenElement; + if (!el) return Promise.resolve(); + el.removeAttribute("data-ios-fullscreen-active"); + document + .querySelectorAll("[data-ios-fullscreen-hidden]") + .forEach((nav) => nav.removeAttribute("data-ios-fullscreen-hidden")); + fullscreenElement = null; + dispatchChange(el); return Promise.resolve(); }; - Object.defineProperty(document, "fullscreenEnabled", { - configurable: true, - get: () => true, - }); - setFullscreenElement(null); - Object.defineProperty(document, "exitFullscreen", { - configurable: true, - value: () => exitPseudoFullscreen(), + override(document, "fullscreenEnabled", { get: () => true }); + override(document, "fullscreenElement", { get: () => fullscreenElement }); + override(document, "exitFullscreen", { value: exit, writable: true }); + override(proto, "requestFullscreen", { + value: function (this: HTMLElement) { + return enter(this); + }, writable: true, }); - Object.defineProperty(htmlElementPrototype, "requestFullscreen", { - configurable: true, - value: function requestFullscreen(this: HTMLElement) { - return enterPseudoFullscreen(this); + override(proto, "webkitRequestFullscreen", { + value: function (this: HTMLElement) { + void enter(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); + void exit(); + styleEl.remove(); + while (overrides.length) { + const { target, key, prev } = overrides.pop()!; + if (prev) Object.defineProperty(target, key, prev); + else Reflect.deleteProperty(target, key); + } }; }