diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 2fbbf9681..f8907d038 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -84,6 +84,7 @@ class Config: HIGH_PRIO_STRUCTURE_PATH: str EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_NETPLAY_ENABLED: bool EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, EjsOption] # core_name -> EjsOption EJS_CONTROLS: dict[str, EjsControls] # core_name -> EjsControls @@ -227,6 +228,9 @@ class ConfigManager: EJS_CACHE_LIMIT=pydash.get( self._raw_config, "emulatorjs.cache_limit", None ), + EJS_NETPLAY_ENABLED=pydash.get( + self._raw_config, "emulatorjs.netplay.enabled", None + ), EJS_NETPLAY_ICE_SERVERS=pydash.get( self._raw_config, "emulatorjs.netplay.ice_servers", [] ), @@ -401,6 +405,11 @@ class ConfigManager: log.critical("Invalid config.yml: emulatorjs.debug must be a boolean") sys.exit(3) + if not isinstance(self.config.EJS_NETPLAY_ENABLED, bool): + log.critical( + "Invalid config.yml: emulatorjs.netplay.enabled must be a boolean" + ) + if self.config.EJS_CACHE_LIMIT is not None and not isinstance( self.config.EJS_CACHE_LIMIT, int ): @@ -524,6 +533,7 @@ class ConfigManager: "debug": self.config.EJS_DEBUG, "cache_limit": self.config.EJS_CACHE_LIMIT, "netplay": { + "enabled": self.config.EJS_NETPLAY_ENABLED, "ice_servers": self.config.EJS_NETPLAY_ICE_SERVERS, }, "settings": self.config.EJS_SETTINGS, diff --git a/backend/endpoints/configs.py b/backend/endpoints/configs.py index bce908d4c..350e7c8bf 100644 --- a/backend/endpoints/configs.py +++ b/backend/endpoints/configs.py @@ -37,6 +37,7 @@ def get_config() -> ConfigResponse: SKIP_HASH_CALCULATION=cfg.SKIP_HASH_CALCULATION, EJS_DEBUG=cfg.EJS_DEBUG, EJS_CACHE_LIMIT=cfg.EJS_CACHE_LIMIT, + EJS_NETPLAY_ENABLED=cfg.EJS_NETPLAY_ENABLED, EJS_NETPLAY_ICE_SERVERS=cfg.EJS_NETPLAY_ICE_SERVERS, EJS_CONTROLS=cfg.EJS_CONTROLS, EJS_SETTINGS=cfg.EJS_SETTINGS, diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index 3779a2546..59f4e6c40 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -17,6 +17,7 @@ class ConfigResponse(TypedDict): SKIP_HASH_CALCULATION: bool EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_NETPLAY_ENABLED: bool EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, dict[str, str]] EJS_CONTROLS: dict[str, EjsControls] diff --git a/backend/tests/config/fixtures/config/config.yml b/backend/tests/config/fixtures/config/config.yml index 415c7fba4..b13131282 100644 --- a/backend/tests/config/fixtures/config/config.yml +++ b/backend/tests/config/fixtures/config/config.yml @@ -56,6 +56,7 @@ emulatorjs: debug: true cache_limit: 1000 netplay: + enabled: true ice_servers: - urls: "stun:stun.relay.metered.ca:80" - urls: "turn:global.relay.metered.ca:80" diff --git a/backend/tests/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 6ca9e0a12..8f92d9432 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -22,6 +22,7 @@ def test_config_loader(): assert loader.config.SKIP_HASH_CALCULATION assert loader.config.EJS_DEBUG assert loader.config.EJS_CACHE_LIMIT == 1000 + assert loader.config.EJS_NETPLAY_ENABLED assert loader.config.EJS_NETPLAY_ICE_SERVERS == [ {"urls": "stun:stun.relay.metered.ca:80"}, { @@ -68,6 +69,7 @@ def test_empty_config_loader(): assert not loader.config.SKIP_HASH_CALCULATION assert not loader.config.EJS_DEBUG assert loader.config.EJS_CACHE_LIMIT is None + assert not loader.config.EJS_NETPLAY_ENABLED assert loader.config.EJS_NETPLAY_ICE_SERVERS == [] assert loader.config.EJS_SETTINGS == {} assert loader.config.EJS_CONTROLS == {} diff --git a/docker/Dockerfile b/docker/Dockerfile index 7793640e8..f176798f3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -104,6 +104,14 @@ RUN apk add --no-cache \ wget \ ca-certificates +ARG EMULATORJS_VERSION=4.2.3 +ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b + +RUN wget "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" && \ + echo "${EMULATORJS_SHA256} ${EMULATORJS_VERSION}.7z" | sha256sum -c - && \ + 7z x -y "${EMULATORJS_VERSION}.7z" -o/emulatorjs && \ + rm -f "${EMULATORJS_VERSION}.7z" + ARG RUFFLE_VERSION=nightly-2025-08-14 ARG RUFFLE_FILE=ruffle-nightly-2025_08_14-web-selfhosted.zip ARG RUFFLE_SHA256=178870c5e7dd825a8df35920dfc5328d83e53f3c4d5d95f70b1ea9cd13494151 @@ -225,6 +233,7 @@ CMD ["/init"] # FULL IMAGE FROM slim-image AS full-image ARG WEBSERVER_FOLDER=/var/www/html +COPY --from=emulator-stage /emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs COPY --from=emulator-stage /ruffle ${WEBSERVER_FOLDER}/assets/ruffle diff --git a/examples/config.example.yml b/examples/config.example.yml index c8816f920..eb8b5fcab 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -116,6 +116,7 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is # default: # These settings apply to all cores # fps: show # netplay: +# enabled: true # ice_servers: # - urls: "stun:stun.relay.metered.ca:80" # - urls: "turn:global.relay.metered.ca:80" diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index 55b7d480d..fcbc45ec8 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -18,6 +18,7 @@ export type ConfigResponse = { SKIP_HASH_CALCULATION: boolean; EJS_DEBUG: boolean; EJS_CACHE_LIMIT: (number | null); + EJS_NETPLAY_ENABLED: boolean; EJS_NETPLAY_ICE_SERVERS: Array; EJS_SETTINGS: Record>; EJS_CONTROLS: Record; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 41f992f05..d6645cdfe 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -84,8 +84,8 @@ export type DetailedRomSchema = { missing_from_fs: boolean; siblings: Array; rom_user: RomUserSchema; - merged_ra_metadata: (RomRAMetadata | null); merged_screenshots: Array; + merged_ra_metadata: (RomRAMetadata | null); user_saves: Array; user_states: Array; user_screenshots: Array; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index f1ac66722..b55eb0970 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -79,7 +79,7 @@ export type SimpleRomSchema = { missing_from_fs: boolean; siblings: Array; rom_user: RomUserSchema; - merged_ra_metadata: (RomRAMetadata | null); merged_screenshots: Array; + merged_ra_metadata: (RomRAMetadata | null); }; diff --git a/frontend/src/console/views/Play.vue b/frontend/src/console/views/Play.vue index 261757a8c..1d00c127e 100644 --- a/frontend/src/console/views/Play.vue +++ b/frontend/src/console/views/Play.vue @@ -607,9 +607,11 @@ async function boot() { // Allow route transition animation to settle await new Promise((r) => setTimeout(r, 50)); - const EMULATORJS_VERSION = "4.2.3"; - const LOCAL_PATH = "/assets/emulatorjs/data/"; - const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`; + const EMULATORJS_VERSION = configStore.config.EJS_NETPLAY_ENABLED + ? "nightly" + : "4.2.3"; + const LOCAL_PATH = "/assets/emulatorjs/data"; + const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`; function loadScript(src: string): Promise { return new Promise((resolve, reject) => { @@ -625,15 +627,19 @@ async function boot() { async function attemptLoad(path: string, label: "local" | "cdn") { loaderStatus.value = label === "local" ? "loading-local" : "loading-cdn"; window.EJS_pathtodata = path; - await loadScript(`${path}loader.js`); + await loadScript(`${path}/loader.js`); } try { try { - await attemptLoad(LOCAL_PATH, "local"); + (await configStore.config.EJS_NETPLAY_ENABLED) + ? attemptLoad(CDN_PATH, "cdn") + : attemptLoad(LOCAL_PATH, "local"); } catch (e) { console.warn("[Play] Local loader failed, trying CDN", e); - await attemptLoad(CDN_PATH, "cdn"); + (await configStore.config.EJS_NETPLAY_ENABLED) + ? attemptLoad(LOCAL_PATH, "local") + : attemptLoad(CDN_PATH, "cdn"); } // Wait for emulator bootstrap const startDeadline = Date.now() + 8000; // 8s diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 632b7302f..d61e6e8d9 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -24,6 +24,7 @@ const defaultConfig = { PLATFORMS_VERSIONS: {}, SKIP_HASH_CALCULATION: false, EJS_DEBUG: false, + EJS_NETPLAY_ENABLED: false, EJS_CACHE_LIMIT: null, EJS_NETPLAY_ICE_SERVERS: [], EJS_SETTINGS: {}, diff --git a/frontend/src/views/Player/EmulatorJS/Base.vue b/frontend/src/views/Player/EmulatorJS/Base.vue index 3f9754173..71ad19e5a 100644 --- a/frontend/src/views/Player/EmulatorJS/Base.vue +++ b/frontend/src/views/Player/EmulatorJS/Base.vue @@ -14,6 +14,7 @@ import { ROUTES } from "@/plugins/router"; import firmwareApi from "@/services/api/firmware"; import romApi from "@/services/api/rom"; import storeAuth from "@/stores/auth"; +import storeConfig from "@/stores/config"; import storePlaying from "@/stores/playing"; import { type DetailedRom } from "@/stores/roms"; import { formatTimestamp, getSupportedEJSCores } from "@/utils"; @@ -21,13 +22,12 @@ import { getEmptyCoverImage } from "@/utils/covers"; import CacheDialog from "@/views/Player/EmulatorJS/CacheDialog.vue"; import Player from "@/views/Player/EmulatorJS/Player.vue"; -const EMULATORJS_VERSION = "nightly"; - const { t, locale } = useI18n(); const { smAndDown } = useDisplay(); const route = useRoute(); const auth = storeAuth(); const playingStore = storePlaying(); +const configStore = storeConfig(); const { playing, fullScreen } = storeToRefs(playingStore); const rom = ref(null); const firmwareOptions = ref([]); @@ -42,7 +42,7 @@ const supportedCores = ref([]); const gameRunning = ref(false); const fullScreenOnPlay = useLocalStorage("emulation.fullScreenOnPlay", true); -function onPlay() { +async function onPlay() { if (rom.value && auth.scopes.includes("roms.user.write")) { romApi.updateUserRomProps({ romId: rom.value.id, @@ -56,33 +56,44 @@ function onPlay() { fullScreen.value = fullScreenOnPlay.value; playing.value = true; - const LOCAL_PATH = "/assets/emulatorjs/data/"; - const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`; + const EMULATORJS_VERSION = configStore.config.EJS_NETPLAY_ENABLED + ? "nightly" + : "4.2.3"; + const LOCAL_PATH = "/assets/emulatorjs/data"; + const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`; - // Try loading local loader.js via fetch to validate it's real JS - fetch(`${LOCAL_PATH}loader.js`) - .then((res) => { - const type = res.headers.get("content-type") || ""; - if (!res.ok || !type.includes("javascript")) { - throw new Error("Invalid local loader.js"); - } - window.EJS_pathtodata = LOCAL_PATH; - return res.text(); - }) - .then((jsCode) => { - playing.value = true; - fullScreen.value = fullScreenOnPlay.value; - const script = document.createElement("script"); - script.textContent = jsCode; - document.body.appendChild(script); - }) - .catch(() => { - console.warn("Local EmulatorJS failed, falling back to CDN"); - window.EJS_pathtodata = CDN_PATH; - const fallbackScript = document.createElement("script"); - fallbackScript.src = `${CDN_PATH}loader.js`; - document.body.appendChild(fallbackScript); + function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.src = src; + s.async = true; + s.onload = () => resolve(); + s.onerror = () => reject(new Error("Failed loading " + src)); + document.body.appendChild(s); }); + } + + async function attemptLoad(path: string) { + window.EJS_pathtodata = path; + await loadScript(`${path}/loader.js`); + } + + try { + try { + await attemptLoad( + configStore.config.EJS_NETPLAY_ENABLED ? CDN_PATH : LOCAL_PATH, + ); + } catch (e) { + console.warn("[Play] Local loader failed, trying CDN", e); + await attemptLoad( + configStore.config.EJS_NETPLAY_ENABLED ? LOCAL_PATH : CDN_PATH, + ); + } + playing.value = true; + fullScreen.value = fullScreenOnPlay.value; + } catch (err) { + console.error("[Play] Emulator load failure:", err); + } } function onFullScreenChange() { diff --git a/frontend/src/views/Player/EmulatorJS/Player.vue b/frontend/src/views/Player/EmulatorJS/Player.vue index 049856ebc..b18461d1b 100644 --- a/frontend/src/views/Player/EmulatorJS/Player.vue +++ b/frontend/src/views/Player/EmulatorJS/Player.vue @@ -144,11 +144,16 @@ window.EJS_gameName = romRef.value.fs_name_no_tags window.EJS_language = selectedLanguage.value.value.replace("_", "-"); window.EJS_disableAutoLang = true; -const { EJS_DEBUG, EJS_CACHE_LIMIT, EJS_NETPLAY_ICE_SERVERS } = - configStore.config; - -window.EJS_netplayServer = window.location.host; -window.EJS_netplayICEServers = EJS_NETPLAY_ICE_SERVERS; +const { + EJS_DEBUG, + EJS_CACHE_LIMIT, + EJS_NETPLAY_ICE_SERVERS, + EJS_NETPLAY_ENABLED, +} = configStore.config; +window.EJS_netplayServer = EJS_NETPLAY_ENABLED ? window.location.host : ""; +window.EJS_netplayICEServers = EJS_NETPLAY_ENABLED + ? EJS_NETPLAY_ICE_SERVERS + : []; if (EJS_CACHE_LIMIT !== null) window.EJS_CacheLimit = EJS_CACHE_LIMIT; window.EJS_DEBUG_XX = EJS_DEBUG;