Merge pull request #2728 from rommapp/emujs-netplay

feat: Netplay
This commit is contained in:
Georges-Antoine Assi
2025-12-09 14:32:23 -05:00
committed by GitHub
25 changed files with 563 additions and 49 deletions

View File

@@ -61,6 +61,12 @@ class EjsControls(TypedDict):
EjsOption = dict[str, str] # option_name -> option_value
class NetplayICEServer(TypedDict):
urls: str
username: NotRequired[str]
credential: NotRequired[str]
class Config:
CONFIG_FILE_MOUNTED: bool
CONFIG_FILE_WRITABLE: bool
@@ -78,6 +84,8 @@ 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
SCAN_METADATA_PRIORITY: list[str]
@@ -220,6 +228,12 @@ 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", False
),
EJS_NETPLAY_ICE_SERVERS=pydash.get(
self._raw_config, "emulatorjs.netplay.ice_servers", []
),
EJS_SETTINGS=pydash.get(self._raw_config, "emulatorjs.settings", {}),
EJS_CONTROLS=self._get_ejs_controls(),
SCAN_METADATA_PRIORITY=pydash.get(
@@ -391,6 +405,12 @@ 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"
)
sys.exit(3)
if self.config.EJS_CACHE_LIMIT is not None and not isinstance(
self.config.EJS_CACHE_LIMIT, int
):
@@ -399,6 +419,12 @@ class ConfigManager:
)
sys.exit(3)
if not isinstance(self.config.EJS_NETPLAY_ICE_SERVERS, list):
log.critical(
"Invalid config.yml: emulatorjs.netplay.ice_servers must be a list"
)
sys.exit(3)
if not isinstance(self.config.EJS_SETTINGS, dict):
log.critical("Invalid config.yml: emulatorjs.settings must be a dictionary")
sys.exit(3)
@@ -507,6 +533,10 @@ class ConfigManager:
"emulatorjs": {
"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,
"controls": self._format_ejs_controls_for_yaml(),
},

View File

@@ -37,6 +37,8 @@ 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,
SCAN_METADATA_PRIORITY=cfg.SCAN_METADATA_PRIORITY,

View File

@@ -0,0 +1,64 @@
from typing import Dict, TypedDict
from fastapi import Request
from decorators.auth import protected_route
from handler.auth.constants import Scope
from handler.netplay_handler import NetplayRoom, netplay_handler
from utils.router import APIRouter
router = APIRouter(
prefix="/netplay",
tags=["netplay"],
)
DEFAULT_MAX_PLAYERS = 4
def _get_owner_player_name(room: NetplayRoom) -> str:
owner_sid = room.get("owner")
if not owner_sid:
return "Unknown"
return next(
(
p["player_name"]
for p in room["players"].values()
if p["socketId"] == owner_sid
),
"Unknown",
)
def _is_room_open(room: NetplayRoom, game_id: str) -> bool:
if len(room["players"]) >= room["max_players"]:
return False
return str(room["game_id"]) == str(game_id)
class RoomsResponse(TypedDict):
room_name: str
current: int
max: int
player_name: str
hasPassword: bool
@protected_route(router.get, "/list", [Scope.ASSETS_READ])
async def get_rooms(request: Request, game_id: str) -> Dict[str, RoomsResponse]:
netplay_rooms = await netplay_handler.get_all()
open_rooms: Dict[str, RoomsResponse] = {
session_id: RoomsResponse(
room_name=room["room_name"],
current=len(room["players"]),
max=room["max_players"],
player_name=_get_owner_player_name(room),
hasPassword=bool(room["password"]),
)
for session_id, room in netplay_rooms.items()
if _is_room_open(room, game_id)
}
return open_rooms

View File

@@ -1,6 +1,6 @@
from typing import TypedDict
from config.config_manager import EjsControls
from config.config_manager import EjsControls, NetplayICEServer
class ConfigResponse(TypedDict):
@@ -17,6 +17,8 @@ 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]
SCAN_METADATA_PRIORITY: list[str]

View File

@@ -0,0 +1,224 @@
from typing import Any, NotRequired, TypedDict
from endpoints.netplay import DEFAULT_MAX_PLAYERS
from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler
from handler.socket_handler import netplay_socket_handler
class RoomDataExtra(TypedDict):
sessionid: str | None
userid: str | None
playerId: str | None
room_name: NotRequired[str]
game_id: NotRequired[str]
domain: NotRequired[str]
player_name: NotRequired[str]
room_password: NotRequired[str]
class RoomData(TypedDict):
extra: RoomDataExtra
maxPlayers: NotRequired[int]
@netplay_socket_handler.socket_server.on("open-room") # type: ignore
async def open_room(sid: str, data: RoomData):
extra_data = data["extra"]
session_id = extra_data.get("sessionid")
player_id = extra_data.get("userid") or extra_data.get("playerId")
if not session_id or not player_id:
return "Invalid data: sessionId and playerId required"
if await netplay_handler.get(session_id):
return "Room already exists"
new_room = NetplayRoom(
owner=sid,
players={
player_id: NetplayPlayerInfo(
socketId=sid,
player_name=extra_data.get("player_name") or f"Player {player_id}",
userid=extra_data.get("userid"),
playerId=extra_data.get("playerId"),
)
},
peers=[],
room_name=extra_data.get("room_name") or f"Room {session_id}",
game_id=extra_data.get("game_id") or "default",
domain=extra_data.get("domain", None),
password=extra_data.get("room_password", None),
max_players=data.get("maxPlayers") or DEFAULT_MAX_PLAYERS,
)
await netplay_handler.set(session_id, new_room)
await netplay_socket_handler.socket_server.enter_room(sid, session_id)
await netplay_socket_handler.socket_server.save_session(
sid,
{
"session_id": session_id,
"player_id": player_id,
},
)
await netplay_socket_handler.socket_server.emit(
"users-updated", new_room["players"], room=session_id
)
@netplay_socket_handler.socket_server.on("join-room") # type: ignore
async def join_room(sid: str, data: RoomData):
extra_data = data["extra"]
session_id = extra_data.get("sessionid")
player_id = extra_data.get("userid") or extra_data.get("playerId")
if not session_id or not player_id:
return "Invalid data: sessionId and playerId required"
current_room = await netplay_handler.get(session_id)
if not current_room:
return "Room not found"
if current_room["password"] and current_room["password"] != extra_data.get(
"room_password"
):
return "Incorrect password"
if len(current_room["players"].keys()) >= current_room["max_players"]:
return "Room is full"
current_room["players"][player_id] = NetplayPlayerInfo(
socketId=sid,
player_name=extra_data.get("player_name") or f"Player {player_id}",
userid=extra_data.get("userid"),
playerId=extra_data.get("playerId"),
)
await netplay_handler.set(session_id, current_room)
await netplay_socket_handler.socket_server.enter_room(sid, session_id)
await netplay_socket_handler.socket_server.save_session(
sid,
{
"session_id": session_id,
"player_id": player_id,
},
)
await netplay_socket_handler.socket_server.emit(
"users-updated", current_room["players"], room=session_id
)
return None, current_room["players"]
async def _handle_leave(sid: str, session_id: str, player_id: str):
current_room = await netplay_handler.get(session_id)
if not current_room:
return
current_room["players"].pop(player_id, None)
if not current_room["players"]:
await netplay_handler.delete([session_id])
# Notify clients that the room is now empty
await netplay_socket_handler.socket_server.emit(
"users-updated", {}, room=session_id
)
return
if sid == current_room["owner"]:
# Owner left, assign a new one
remaining_players = list(current_room["players"].values())
if remaining_players:
current_room["owner"] = remaining_players[0]["socketId"]
await netplay_handler.set(session_id, current_room)
await netplay_socket_handler.socket_server.emit(
"users-updated", current_room["players"], room=session_id
)
@netplay_socket_handler.socket_server.on("leave-room") # type: ignore
async def leave_room(sid: str):
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
session_id = stored_session.get("session_id")
player_id = stored_session.get("player_id")
if session_id and player_id:
await _handle_leave(sid, session_id, player_id)
await netplay_socket_handler.socket_server.leave_room(sid, session_id)
class WebRTCSignalData(TypedDict, total=False):
target: str
candidate: Any
offer: Any
answer: Any
requestRenegotiate: bool
@netplay_socket_handler.socket_server.on("webrtc-signal") # type: ignore
async def webrtc_signal(sid: str, data: WebRTCSignalData):
target = data.get("target")
request_renegotiate = data.get("requestRenegotiate", False)
if request_renegotiate:
if not target:
return
await netplay_socket_handler.socket_server.emit(
"webrtc-signal",
{"sender": sid, "requestRenegotiate": True},
to=target,
)
else:
if not target:
return # drop message—no recipient
await netplay_socket_handler.socket_server.emit(
"webrtc-signal",
{
"sender": sid,
"candidate": data.get("candidate"),
"offer": data.get("offer"),
"answer": data.get("answer"),
},
to=target,
)
@netplay_socket_handler.socket_server.on("webrtc-signal-error") # type: ignore
async def webrtc_signal_error(_sid: str, _error: str, _data: Any):
pass
@netplay_socket_handler.socket_server.on("disconnect") # type: ignore
async def disconnect(sid: str):
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
session_id = stored_session.get("session_id")
player_id = stored_session.get("player_id")
if session_id and player_id:
await _handle_leave(sid, session_id, player_id)
async def _broadcast_to_room(sid: str, event: str, data: Any):
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
session_id = stored_session.get("session_id")
if session_id:
await netplay_socket_handler.socket_server.emit(
event, data, room=session_id, skip_sid=sid
)
@netplay_socket_handler.socket_server.on("data-message") # type: ignore
async def data_message(sid: str, data: Any):
await _broadcast_to_room(sid, "data-message", data)
@netplay_socket_handler.socket_server.on("snapshot") # type: ignore
async def snapshot(sid: str, data: Any):
await _broadcast_to_room(sid, "snapshot", data)
@netplay_socket_handler.socket_server.on("input") # type: ignore
async def input(sid: str, data: Any):
await _broadcast_to_room(sid, "input", data)

View File

@@ -0,0 +1,50 @@
import json
from typing import Optional, TypedDict
from handler.redis_handler import async_cache
class NetplayPlayerInfo(TypedDict):
socketId: str
player_name: str
userid: str | None
playerId: str | None
class NetplayRoom(TypedDict):
owner: str
players: dict[str, NetplayPlayerInfo]
peers: list[str]
room_name: str
game_id: str
domain: Optional[str]
password: Optional[str]
max_players: int
class NetplayHandler:
"""A class to handle netplay rooms in Redis."""
def __init__(self):
self.hash_name = "netplay:rooms"
async def get(self, room_id: str) -> NetplayRoom | None:
"""Get a room from Redis."""
room = await async_cache.hget(self.hash_name, room_id)
return json.loads(room) if room else None
async def set(self, room_id: str, room_data: NetplayRoom):
"""Set a room in Redis."""
return await async_cache.hset(self.hash_name, room_id, json.dumps(room_data))
async def delete(self, room_ids: list[str]):
"""Delete a room from Redis."""
return await async_cache.hdel(self.hash_name, *room_ids)
async def get_all(self) -> dict[str, NetplayRoom]:
"""Get all rooms from Redis."""
rooms = await async_cache.hgetall(self.hash_name)
return {room_id: json.loads(room_data) for room_id, room_data in rooms.items()}
netplay_handler = NetplayHandler()

View File

@@ -5,7 +5,7 @@ from utils import json_module
class SocketHandler:
def __init__(self) -> None:
def __init__(self, path: str) -> None:
self.socket_server = socketio.AsyncServer(
cors_allowed_origins="*",
async_mode="asgi",
@@ -19,9 +19,8 @@ class SocketHandler:
cors_credentials=True,
)
self.socket_app = socketio.ASGIApp(
self.socket_server, socketio_path="/ws/socket.io"
)
self.socket_app = socketio.ASGIApp(self.socket_server, socketio_path=path)
socket_handler = SocketHandler()
socket_handler = SocketHandler(path="/ws/socket.io")
netplay_socket_handler = SocketHandler(path="/netplay/socket.io")

View File

@@ -13,6 +13,7 @@ from fastapi_pagination import add_pagination
from starlette.middleware.authentication import AuthenticationMiddleware
from startup import main
import endpoints.sockets.netplay # noqa
import endpoints.sockets.scan # noqa
from config import (
DEV_HOST,
@@ -31,6 +32,7 @@ from endpoints import (
firmware,
gamelist,
heartbeat,
netplay,
platform,
raw,
rom,
@@ -45,7 +47,7 @@ from endpoints import (
from handler.auth.hybrid_auth import HybridAuthBackend
from handler.auth.middleware.csrf_middleware import CSRFMiddleware
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
from handler.socket_handler import socket_handler
from handler.socket_handler import netplay_socket_handler, socket_handler
from logger.formatter import LOGGING_CONFIG
from utils import get_version
from utils.context import (
@@ -93,7 +95,11 @@ if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION:
CSRFMiddleware,
cookie_name="romm_csrftoken",
secret=ROMM_AUTH_SECRET_KEY,
exempt_urls=[re.compile(r"^/api/token.*"), re.compile(r"^/ws")],
exempt_urls=[
re.compile(r"^/api/token.*"),
re.compile(r"^/ws"),
re.compile(r"^/netplay"),
],
)
# Handles both basic and oauth authentication
@@ -130,8 +136,10 @@ app.include_router(screenshots.router, prefix="/api")
app.include_router(firmware.router, prefix="/api")
app.include_router(collections.router, prefix="/api")
app.include_router(gamelist.router, prefix="/api")
app.include_router(netplay.router, prefix="/api")
app.mount("/ws", socket_handler.socket_app)
app.mount("/netplay", netplay_socket_handler.socket_app)
add_pagination(app)

View File

@@ -24,6 +24,7 @@ from handler.metadata.base_handler import (
from handler.redis_handler import async_cache
from logger.logger import log
from models.firmware import FIRMWARE_FIXTURES_DIR, KNOWN_BIOS_KEY
from tasks.scheduled.cleanup_netplay import cleanup_netplay_task
from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task
from tasks.scheduled.scan_library import scan_library_task
from tasks.scheduled.sync_retroachievements_progress import (
@@ -46,6 +47,8 @@ async def main() -> None:
log.info("Running startup tasks")
# Initialize scheduled tasks
cleanup_netplay_task.init()
if ENABLE_SCHEDULED_RESCAN:
log.info("Starting scheduled rescan")
scan_library_task.init()

View File

@@ -0,0 +1,32 @@
from handler.netplay_handler import netplay_handler
from logger.logger import log
from tasks.tasks import PeriodicTask, TaskType
class CleanupNetplayTask(PeriodicTask):
def __init__(self):
super().__init__(
title="Scheduled netplay cleanup",
description="Cleans up empty netplay rooms",
task_type=TaskType.CLEANUP,
enabled=True,
manual_run=False,
cron_string="*/5 * * * *", # Every 5 minutes
func="tasks.scheduled.cleanup_netplay.cleanup_netplay_task.run",
)
async def run(self) -> None:
if not self.enabled:
self.unschedule()
return
netplay_rooms = await netplay_handler.get_all()
rooms_to_delete = [
sid for sid, r in netplay_rooms.items() if len(r.get("players", {})) == 0
]
if rooms_to_delete:
log.info(f"Cleaning up {len(rooms_to_delete)} empty netplay rooms")
await netplay_handler.delete(rooms_to_delete)
cleanup_netplay_task = CleanupNetplayTask()

View File

@@ -55,6 +55,13 @@ scan:
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"
username: "user"
credential: "password"
settings:
parallel_n64:
vsync: disabled

View File

@@ -22,6 +22,15 @@ 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"},
{
"urls": "turn:global.relay.metered.ca:80",
"username": "user",
"credential": "password",
},
]
assert loader.config.EJS_SETTINGS == {
"parallel_n64": {"vsync": "disabled"},
"snes9x": {"snes9x_region": "ntsc"},
@@ -60,5 +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 == {}

View File

@@ -57,7 +57,7 @@ server {
proxy_request_buffering off;
proxy_buffering off;
}
location /ws {
location ~ ^/(ws|netplay) {
proxy_pass http://wsgi_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;

View File

@@ -115,6 +115,13 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is
# snes9x_region: ntsc
# 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"
# username: "<username>"
# credential: "<password>"
# controls: # https://emulatorjs.org/docs4devs/control-mapping/
# snes9x:
# 0: # Player 1

View File

@@ -51,6 +51,7 @@ export type { JobStatus } from './models/JobStatus';
export type { LaunchboxImage } from './models/LaunchboxImage';
export type { MetadataSourcesDict } from './models/MetadataSourcesDict';
export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform';
export type { NetplayICEServer } from './models/NetplayICEServer';
export type { OIDCDict } from './models/OIDCDict';
export type { PlatformSchema } from './models/PlatformSchema';
export type { RAGameRomAchievement } from './models/RAGameRomAchievement';
@@ -71,6 +72,7 @@ export type { RomRAMetadata } from './models/RomRAMetadata';
export type { RomSSMetadata } from './models/RomSSMetadata';
export type { RomUserSchema } from './models/RomUserSchema';
export type { RomUserStatus } from './models/RomUserStatus';
export type { RoomsResponse } from './models/RoomsResponse';
export type { SaveSchema } from './models/SaveSchema';
export type { ScanStats } from './models/ScanStats';
export type { ScanTaskMeta } from './models/ScanTaskMeta';

View File

@@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
import type { EjsControls } from './EjsControls';
import type { NetplayICEServer } from './NetplayICEServer';
export type ConfigResponse = {
CONFIG_FILE_MOUNTED: boolean;
CONFIG_FILE_WRITABLE: boolean;
@@ -17,6 +18,8 @@ export type ConfigResponse = {
SKIP_HASH_CALCULATION: boolean;
EJS_DEBUG: boolean;
EJS_CACHE_LIMIT: (number | null);
EJS_NETPLAY_ENABLED: boolean;
EJS_NETPLAY_ICE_SERVERS: Array<NetplayICEServer>;
EJS_SETTINGS: Record<string, Record<string, string>>;
EJS_CONTROLS: Record<string, EjsControls>;
SCAN_METADATA_PRIORITY: Array<string>;

View File

@@ -84,8 +84,8 @@ export type DetailedRomSchema = {
missing_from_fs: boolean;
siblings: Array<SiblingRomSchema>;
rom_user: RomUserSchema;
merged_ra_metadata: (RomRAMetadata | null);
merged_screenshots: Array<string>;
merged_ra_metadata: (RomRAMetadata | null);
user_saves: Array<SaveSchema>;
user_states: Array<StateSchema>;
user_screenshots: Array<ScreenshotSchema>;

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type NetplayICEServer = {
urls: string;
username?: string;
credential?: string;
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RoomsResponse = {
room_name: string;
current: number;
max: number;
player_name: string;
hasPassword: boolean;
};

View File

@@ -79,7 +79,7 @@ export type SimpleRomSchema = {
missing_from_fs: boolean;
siblings: Array<SiblingRomSchema>;
rom_user: RomUserSchema;
merged_ra_metadata: (RomRAMetadata | null);
merged_screenshots: Array<string>;
merged_ra_metadata: (RomRAMetadata | null);
};

View File

@@ -607,9 +607,10 @@ 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 { EJS_NETPLAY_ENABLED } = configStore.config;
const EMULATORJS_VERSION = 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<void> {
return new Promise((resolve, reject) => {
@@ -622,18 +623,20 @@ async function boot() {
});
}
async function attemptLoad(path: string, label: "local" | "cdn") {
async function attemptLoad(label: "local" | "cdn") {
const path = label === "local" ? LOCAL_PATH : CDN_PATH;
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 attemptLoad(EJS_NETPLAY_ENABLED ? "cdn" : "local");
} catch (e) {
console.warn("[Play] Local loader failed, trying CDN", e);
await attemptLoad(CDN_PATH, "cdn");
await attemptLoad(EJS_NETPLAY_ENABLED ? "local" : "cdn");
}
// Wait for emulator bootstrap
const startDeadline = Date.now() + 8000; // 8s

View File

@@ -24,7 +24,9 @@ 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: {},
EJS_CONTROLS: {},
SCAN_METADATA_PRIORITY: [],

View File

@@ -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 = "4.2.3";
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<DetailedRom | null>(null);
const firmwareOptions = ref<FirmwareSchema[]>([]);
@@ -42,7 +42,7 @@ const supportedCores = ref<string[]>([]);
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,39 @@ 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 { EJS_NETPLAY_ENABLED } = configStore.config;
const EMULATORJS_VERSION = 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<void> {
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(EJS_NETPLAY_ENABLED ? CDN_PATH : LOCAL_PATH);
} catch (e) {
console.warn("[Play] Local loader failed, trying CDN", e);
await attemptLoad(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() {

View File

@@ -4,7 +4,12 @@ import { storeToRefs } from "pinia";
import { inject, onBeforeUnmount, onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useTheme } from "vuetify";
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
import type {
FirmwareSchema,
SaveSchema,
StateSchema,
NetplayICEServer,
} from "@/__generated__";
import { ROUTES } from "@/plugins/router";
import { saveApi as api } from "@/services/api/save";
import storeConfig from "@/stores/config";
@@ -69,6 +74,7 @@ declare global {
EJS_gameParentUrl: string;
EJS_gamePatchUrl: string;
EJS_netplayServer: string;
EJS_netplayICEServers: NetplayICEServer[];
EJS_alignStartButton: "top" | "center" | "bottom";
EJS_startOnLoaded: boolean;
EJS_fullscreenOnLoaded: boolean;
@@ -138,7 +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 } = configStore.config;
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;
@@ -374,6 +389,28 @@ window.EJS_onGameStart = async () => {
romsStore.update(romRef.value);
immediateExit();
});
// The netplay implementation is finnicky, these overrides make it work
const { defineNetplayFunctions } = window.EJS_emulator;
window.EJS_emulator.defineNetplayFunctions = () => {
defineNetplayFunctions.bind(window.EJS_emulator)();
window.EJS_emulator.netplay.url = {
path: "/netplay/socket.io",
};
window.EJS_emulator.netplayGetOpenRooms = async () => {
try {
const response = await fetch(
`/api/netplay/list?game_id=${window.EJS_gameID}`,
);
return await response.json();
} catch (error) {
console.error("Error fetching open rooms:", error);
return {};
}
};
};
};
function immediateExit() {

View File

@@ -123,7 +123,7 @@ export default defineConfig(({ mode }) => {
changeOrigin: false,
secure: false,
},
"/ws": {
"^/(?:ws|netplay)": {
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: false,
ws: true,