Merge pull request #3431 from rommapp/copilot/fix-fullscreen-emulation-ios

Add iOS pseudo-fullscreen shim for EmulatorJS player
This commit is contained in:
Georges-Antoine Assi
2026-05-25 10:45:38 -04:00
committed by GitHub
2 changed files with 120 additions and 0 deletions

View File

@@ -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<number | null>(null);
const selectedCore = ref<string | null>(null);
const selectedFirmware = ref<FirmwareSchema | null>(null);
const supportedCores = ref<string[]>([]);
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);
});

View File

@@ -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<HTMLElement>(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<HTMLElement>("[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";