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..5aba36074 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,6 +146,116 @@ export function loadEmulatorJSState(state: Uint8Array) { window.EJS_emulator.gameManager.loadState(state); } +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 ( + osName === "ios" || + // iPadOS 13+ reports as macOS with touch support, so fall back to that check. + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) + ); +} + +export function installIOSFullscreenShim() { + if (!isIOSFullscreenShimRequired()) { + return () => {}; + } + + 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), + }); + Object.defineProperty(target, key, { configurable: true, ...descriptor }); + }; + + const styleEl = document.createElement("style"); + styleEl.textContent = IOS_FULLSCREEN_STYLE; + document.head.appendChild(styleEl); + + let fullscreenElement: HTMLElement | null = null; + + const dispatchChange = (target: HTMLElement) => { + document.dispatchEvent(new Event("fullscreenchange")); + target.dispatchEvent(new Event("fullscreenchange")); + }; + + 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 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(); + }; + + 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, + }); + override(proto, "webkitRequestFullscreen", { + value: function (this: HTMLElement) { + void enter(this); + }, + writable: true, + }); + + return () => { + 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); + } + }; +} + export function createQuickLoadButton(): HTMLButtonElement { const button = document.createElement("button"); button.type = "button";