Repository: libretro/RetroArch
Pull request #17872: Emscripten improvements pt. 4
Author: login: BinBashBanana
Created: 2025-05-05T16:34:58Z | Merged: 2025-05-09T00:49:30Z

Changes:
- Makefile is cleaner again, thanks to emscripten 4.0.8 inlining the audio worklet javascript module.
- Major rwebcam fixups. Works with and without proxy to pthread, and with both raw framebuffer (tested with mGBA) and GL texture (tested with 3D engine, see https://github.com/libretro/libretro-3dengine/pull/14)
    - Camera permission is only requested on start rather than init, so the camera won't run when it's not supposed to
- Added WebGL context loss/recovery handling. Cores that use OpenGL still need to be reinited manually.
- Added window resizing (a bit wonky, opt in with `ENV["ENABLE_SET_CANVAS_SIZE"] = "1"`, requires the canvas size to be based on window size
- Added fullscreen integration -- embedders can also define `Module.fullscreenEnter` and `Module.fullscreenExit` which will be used instead of the default which is to just make the canvas fullscreen.
- Added window "focus" detection, currently based on whether the window/tab is visible, I'm not sure how to check for focus
- Added suspend screensaver support
- Moved `retro_sleep` definition to `platform_emscripten.c` for emscripten, this should make it easier to build cores that want to use it (since it is dependent on frontend build settings).
- Added checks for canvas dimensions to prevent infinite resizing
- Set canvas background color to black using CSS rather than `alpha: false` in the context attributes, as apparently that can have a performance impact on some platforms ([source](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices#avoid_alphafalse_which_can_be_expensive)). This also works for the egl context driver
- Added accelerometer/gyroscope for rwebinput
- Added rumble for rwebpad


Diff:
diff --git a/CHANGES.md b/CHANGES.md
index a9ad792ab4ec..1cb87c7e4431 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,8 @@
 # Future
+- EMSCRIPTEN: Support suspend screensaver
+- EMSCRIPTEN/RWEBCAM: Fix camera driver
+- EMSCRIPTEN/RWEBINPUT: Add accelerometer/gyroscope support
+- EMSCRIPTEN/RWEBPAD: Add rumble support
 
 # 1.21.0
 - 3DS: Fix unique IDs for newer cores
diff --git a/Makefile.emscripten b/Makefile.emscripten
index 0d44cb25803b..7f3fc7b43703 100644
--- a/Makefile.emscripten
+++ b/Makefile.emscripten
@@ -11,7 +11,7 @@ TARGET_BASE := $(subst .js,,$(TARGET))
 
 OS = Emscripten
 OBJ :=
-DEFINES := -DRARCH_INTERNAL -DHAVE_MAIN -DEMSCRIPTEN -DNO_CANVAS_RESIZE
+DEFINES := -DRARCH_INTERNAL -DHAVE_MAIN -DEMSCRIPTEN
 DEFINES += -DHAVE_FILTERS_BUILTIN -DHAVE_ONLINE_UPDATER -DHAVE_UPDATE_ASSETS -DHAVE_UPDATE_CORE_INFO
 
 HAVE_PATCH = 1
@@ -106,6 +106,9 @@ ASYNC ?= 0
 LTO ?= 0
 PTHREAD_POOL_SIZE ?= 4
 
+ASYNCIFY_ADD ?= dynCall_*,emscripten_mainloop
+ASYNCIFY_REMOVE ?= threaded_worker
+
 STACK_SIZE ?= 4194304
 INITIAL_HEAP ?= 134217728
 
@@ -123,7 +126,7 @@ INITIAL_HEAP ?= 134217728
 
 OBJDIR := obj-emscripten
 
-EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_take_screenshot,\
+EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_toggle_fullscreen,_cmd_take_screenshot,\
 _cmd_toggle_menu,_cmd_reload_config,_cmd_toggle_grab_mouse,_cmd_toggle_game_focus,_cmd_reset,_cmd_toggle_pause,_cmd_pause,_cmd_unpause,\
 _cmd_set_volume,_cmd_set_shader,_cmd_cheat_set_code,_cmd_cheat_get_code,_cmd_cheat_toggle_index,_cmd_cheat_get_code_state,_cmd_cheat_realloc,\
 _cmd_cheat_get_size,_cmd_cheat_apply_cheats,EmscriptenSendCommand,EmscriptenReceiveCommandReply
@@ -168,7 +171,7 @@ LDFLAGS := -L. --no-heap-copy -s STACK_SIZE=$(STACK_SIZE) -s INITIAL_MEMORY=$(IN
            -s ALLOW_MEMORY_GROWTH=1 -s EXPORTED_FUNCTIONS="$(EXPORTED_FUNCTIONS)" \
            -s MODULARIZE=1 -s EXPORT_ES6=1 -s EXPORT_NAME="libretro_$(subst -,_,$(LIBRETRO))" \
            -s DISABLE_DEPRECATED_FIND_EVENT_TARGET_BEHAVIOR=0 \
-           -s ENVIRONMENT=web,worker \
+           -s ENVIRONMENT=web,worker -s WASM_BIGINT=1 \
            --extern-pre-js emscripten/pre.js \
            --js-library emscripten/library_rwebcam.js \
            --js-library emscripten/library_platform_emscripten.js
@@ -240,7 +243,7 @@ ifeq ($(HAVE_THREADS), 1)
 endif
 
 ifeq ($(WASM_WORKERS), 1)
-   LDFLAGS += -s WASM_WORKERS=2
+   LDFLAGS += -s WASM_WORKERS=1
 endif
 
 ifeq ($(ASYNC), 1)
@@ -251,7 +254,7 @@ ifeq ($(ASYNC), 1)
    endif
 else ifeq ($(MIN_ASYNC), 1)
    DEFINES += -DEMSCRIPTEN_ASYNCIFY -DEMSCRIPTEN_MIN_ASYNCIFY
-   LDFLAGS += -s ASYNCIFY=1 -s ASYNCIFY_STACK_SIZE=8192 -s ASYNCIFY_IGNORE_INDIRECT=1 -s ASYNCIFY_ADD='dynCall_*,emscripten_mainloop' -s ASYNCIFY_REMOVE='threaded_worker'
+   LDFLAGS += -s ASYNCIFY=1 -s ASYNCIFY_STACK_SIZE=8192 -s ASYNCIFY_IGNORE_INDIRECT=1 -s ASYNCIFY_ADD='$(ASYNCIFY_ADD)' -s ASYNCIFY_REMOVE='$(ASYNCIFY_REMOVE)'
    ifeq ($(DEBUG), 1)
       LDFLAGS += -s ASYNCIFY_ADVISE #-s ASYNCIFY_DEBUG=1
    endif
@@ -297,21 +300,11 @@ all: $(TARGET)
 $(libretro_new): ;
 
 mv_libretro:
-	mv -f $(libretro) $(libretro_new) || true
+	$(Q)mv -f $(libretro) $(libretro_new) || true
 
-# until emscripten adds something like WASM_WORKERS=2 but for audio worklets, DIY.
-ifeq ($(HAVE_AUDIOWORKLET), 1)
 $(TARGET): $(RARCH_OBJ) $(libretro_new) mv_libretro
 	@$(if $(Q), $(shell echo echo "LD $@ \<obj\> $(libretro_new) $(LIBS) $(LDFLAGS)"),)
 	$(Q)$(LD) -o $@ $(RARCH_OBJ) $(libretro_new) $(LIBS) $(LDFLAGS)
-	$(Q)tr -d '\n' < "$(TARGET_BASE).aw.js" | sed -e "s/[\/&]/\\\\&/g" -e "s/'/\\\\\\\\&/g" > _audioworklet.js
-	$(Q)sed -i.bak -e "s/\"$(TARGET_BASE)\.aw\.js\"/URL.createObjectURL(new Blob(['$$(cat _audioworklet.js)'],{type:'text\/javascript'}))/" -- "$@"
-	$(Q)rm -f "$(TARGET_BASE).aw.js" _audioworklet.js "$@".bak
-else
-$(TARGET): $(RARCH_OBJ) $(libretro_new) mv_libretro
-	@$(if $(Q), $(shell echo echo "LD $@ \<obj\> $(libretro_new) $(LIBS) $(LDFLAGS)"),)
-	$(Q)$(LD) -o $@ $(RARCH_OBJ) $(libretro_new) $(LIBS) $(LDFLAGS)
-endif
 
 $(OBJDIR)/%.o: %.c
 	@mkdir -p $(dir $@)
diff --git a/audio/drivers/audioworklet.c b/audio/drivers/audioworklet.c
index d5425ff88a4b..adcaa67c9659 100644
--- a/audio/drivers/audioworklet.c
+++ b/audio/drivers/audioworklet.c
@@ -382,6 +382,9 @@ bool audioworklet_external_block(void)
 {
    audioworklet_data_t *audioworklet = audioworklet_static_data;
 
+   if (!audioworklet)
+      return false;
+
 #ifdef EMSCRIPTEN_AUDIO_FAKE_BLOCK
    if (!audioworklet->block_requested)
       return false;
diff --git a/camera/drivers/rwebcam.c b/camera/drivers/rwebcam.c
index 5a08e951ac16..292be416e7d9 100644
--- a/camera/drivers/rwebcam.c
+++ b/camera/drivers/rwebcam.c
@@ -14,6 +14,7 @@
  *  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include <stdlib.h>
 #include <stdint.h>
 
 #include <boolean.h>
@@ -23,7 +24,7 @@
 #include "../../retroarch.h"
 
 /* forward declarations */
-void *RWebCamInit(uint64_t caps, unsigned width, unsigned height);
+void *RWebCamInit(uint64_t caps, unsigned width, unsigned height, bool debug);
 void RWebCamFree(void *data);
 bool RWebCamStart(void *data);
 void RWebCamStop(void *data);
@@ -34,7 +35,7 @@ static void *rwebcam_init(const char *device, uint64_t caps,
       unsigned width, unsigned height)
 {
    (void)device;
-   return RWebCamInit(caps, width, height);
+   return RWebCamInit(caps, width, height, !!getenv("RWEBCAM_DEBUG"));
 }
 
 static void rwebcam_free(void *data)
diff --git a/config.def.h b/config.def.h
index d31f27feab61..0bfb35bd9b8a 100644
--- a/config.def.h
+++ b/config.def.h
@@ -1290,11 +1290,7 @@
 #endif
 
 /* Pause gameplay when window loses focus. */
-#if defined(EMSCRIPTEN)
-#define DEFAULT_PAUSE_NONACTIVE false
-#else
 #define DEFAULT_PAUSE_NONACTIVE true
-#endif
 
 /* Pause gameplay when controller disconnects. */
 #define DEFAULT_PAUSE_ON_DISCONNECT false
diff --git a/emscripten/library_platform_emscripten.js b/emscripten/library_platform_emscripten.js
index 3d015a564999..4edfb92a0c5c 100644
--- a/emscripten/library_platform_emscripten.js
+++ b/emscripten/library_platform_emscripten.js
@@ -2,23 +2,14 @@
 
 var LibraryPlatformEmscripten = {
    $RPE: {
-      powerStateChange: function(e) {
-         _platform_emscripten_update_power_state(true, Number.isFinite(e.target.dischargingTime) ? e.target.dischargingTime : 0x7FFFFFFF, e.target.level, e.target.charging);
-      },
-
-      updateMemoryUsage: function() {
-         // unfortunately this will be innacurate in threaded (worker) builds
-         var used = BigInt(performance.memory.usedJSHeapSize || 0);
-         var limit = BigInt(performance.memory.jsHeapSizeLimit || 0);
-         // emscripten currently only supports passing 32 bit ints, so pack it
-         _platform_emscripten_update_memory_usage(Number(used & 0xFFFFFFFFn), Number(used >> 32n), Number(limit & 0xFFFFFFFFn), Number(limit >> 32n));
-         setTimeout(RPE.updateMemoryUsage, 5000);
-      },
+      canvasWidth: 0,
+      canvasHeight: 0,
+      sentinelPromise: null,
       command_queue: [],
       command_reply_queue: []
    },
 
-   PlatformEmscriptenWatchCanvasSizeAndDpr__deps: ["platform_emscripten_update_canvas_dimensions"],
+   PlatformEmscriptenWatchCanvasSizeAndDpr__deps: ["platform_emscripten_update_canvas_dimensions_cb"],
    PlatformEmscriptenWatchCanvasSizeAndDpr: function(dpr) {
       if (RPE.observer) {
          RPE.observer.unobserve(Module.canvas);
@@ -36,9 +27,11 @@ var LibraryPlatformEmscripten = {
             width = Math.round(entry.contentRect.width * window.devicePixelRatio);
             height = Math.round(entry.contentRect.height * window.devicePixelRatio);
          }
+         RPE.canvasWidth = width;
+         RPE.canvasHeight = height;
          // doubles are too big to pass as an argument to exported functions
          {{{ makeSetValue("dpr", "0", "window.devicePixelRatio", "double") }}};
-         _platform_emscripten_update_canvas_dimensions(width, height, dpr);
+         _platform_emscripten_update_canvas_dimensions_cb(width, height, dpr);
       });
       RPE.observer.observe(Module.canvas);
       window.addEventListener("resize", function() {
@@ -47,27 +40,112 @@ var LibraryPlatformEmscripten = {
       }, false);
    },
 
-   PlatformEmscriptenWatchWindowVisibility__deps: ["platform_emscripten_update_window_hidden"],
+   PlatformEmscriptenWatchWindowVisibility__deps: ["platform_emscripten_update_window_hidden_cb"],
    PlatformEmscriptenWatchWindowVisibility: function() {
       document.addEventListener("visibilitychange", function() {
-         _platform_emscripten_update_window_hidden(document.visibilityState == "hidden");
+         _platform_emscripten_update_window_hidden_cb(document.visibilityState == "hidden");
       }, false);
    },
 
-   PlatformEmscriptenPowerStateInit__deps: ["platform_emscripten_update_power_state"],
+   $PlatformEmscriptenPowerStateChange__deps: ["platform_emscripten_update_power_state_cb"],
+   $PlatformEmscriptenPowerStateChange: function(e) {
+      _platform_emscripten_update_power_state_cb(true, Number.isFinite(e.target.dischargingTime) ? e.target.dischargingTime : 0x7FFFFFFF, e.target.level, e.target.charging);
+   },
+
+   PlatformEmscriptenPowerStateInit__deps: ["$PlatformEmscriptenPowerStateChange"],
    PlatformEmscriptenPowerStateInit: function() {
       if (!navigator.getBattery) return;
       navigator.getBattery().then(function(battery) {
-         battery.addEventListener("chargingchange", RPE.powerStateChange);
-         battery.addEventListener("levelchange", RPE.powerStateChange);
-         RPE.powerStateChange({target: battery});
+         battery.addEventListener("chargingchange", PlatformEmscriptenPowerStateChange);
+         battery.addEventListener("levelchange", PlatformEmscriptenPowerStateChange);
+         PlatformEmscriptenPowerStateChange({target: battery});
       });
    },
 
-   PlatformEmscriptenMemoryUsageInit__deps: ["platform_emscripten_update_memory_usage"],
+   $PlatformEmscriptenUpdateMemoryUsage__deps: ["platform_emscripten_update_memory_usage_cb"],
+   $PlatformEmscriptenUpdateMemoryUsage: function() {
+      // unfortunately this will be inaccurate in threaded (worker) builds
+      _platform_emscripten_update_memory_usage_cb(BigInt(performance.memory.usedJSHeapSize || 0), BigInt(performance.memory.jsHeapSizeLimit || 0));
+      setTimeout(PlatformEmscriptenUpdateMemoryUsage, 5000);
+   },
+
+   PlatformEmscriptenMemoryUsageInit__deps: ["$PlatformEmscriptenUpdateMemoryUsage"],
    PlatformEmscriptenMemoryUsageInit: function() {
       if (!performance.memory) return;
-      RPE.updateMemoryUsage();
+      PlatformEmscriptenUpdateMemoryUsage();
+   },
+
+   PlatformEmscriptenWatchFullscreen__deps: ["platform_emscripten_update_fullscreen_state_cb"],
+   PlatformEmscriptenWatchFullscreen: function() {
+      document.addEventListener("fullscreenchange", function() {
+         _platform_emscripten_update_fullscreen_state_cb(!!document.fullscreenElement);
+      }, false);
+   },
+
+   PlatformEmscriptenGLContextEventInit__deps: ["platform_emscripten_gl_context_lost_cb", "platform_emscripten_gl_context_restored_cb"],
+   PlatformEmscriptenGLContextEventInit: function() {
+      Module.canvas.addEventListener("webglcontextlost", function(e) {
+         e.preventDefault();
+         _platform_emscripten_gl_context_lost_cb();
+      });
+      Module.canvas.addEventListener("webglcontextrestored", function() {
+         _platform_emscripten_gl_context_restored_cb();
+      });
+   },
+
+   $PlatformEmscriptenDoSetCanvasSize: async function(width, height) {
+      // window.resizeTo is terrible; it uses window.outerWidth/outerHeight dimensions,
+      // which are on their own scale entirely, which also changes with the zoom level independently from the DPR.
+      // instead try 2 hardcoded sizes first and interpolate to find the correct size.
+      var expAX = 600;
+      var expAY = 450;
+      var expBX = expAX + 100;
+      var expBY = expAY + 100;
+      await new Promise(r => setTimeout(r, 0));
+      window.resizeTo(expAX, expAY);
+      await new Promise(r => setTimeout(r, 50));
+      var oldWidth = RPE.canvasWidth;
+      var oldHeight = RPE.canvasHeight;
+      window.resizeTo(expBX, expBY);
+      await new Promise(r => setTimeout(r, 50));
+      var projX = (expBX - expAX) * (width - oldWidth) / (RPE.canvasWidth - oldWidth) + expAX;
+      var projY = (expBY - expAY) * (height - oldHeight) / (RPE.canvasHeight - oldHeight) + expAY;
+      window.resizeTo(Math.round(projX), Math.round(projY));
+   },
+
+   PlatformEmscriptenSetCanvasSize__proxy: "sync",
+   PlatformEmscriptenSetCanvasSize__deps: ["$PlatformEmscriptenDoSetCanvasSize"],
+   PlatformEmscriptenSetCanvasSize: function(width, height) {
+      // c-accessible library functions should not be async
+      PlatformEmscriptenDoSetCanvasSize(width, height);
+   },
+
+   $PlatformEmscriptenDoSetWakeLock: async function(state) {
+      if (state && !RPE.sentinelPromise && navigator?.wakeLock?.request) {
+         try {
+            RPE.sentinelPromise = navigator.wakeLock.request("screen");
+         } catch (e) {}
+      } else if (!state && RPE.sentinelPromise) {
+         try {
+            var sentinel = await RPE.sentinelPromise;
+            sentinel.release();
+         } catch (e) {}
+         RPE.sentinelPromise = null;
+      }
+   },
+
+   PlatformEmscriptenSetWakeLock__proxy: "sync",
+   PlatformEmscriptenSetWakeLock__deps: ["$PlatformEmscriptenDoSetWakeLock"],
+   PlatformEmscriptenSetWakeLock: function(state) {
+      PlatformEmscriptenDoSetWakeLock(state);
+   },
+
+   PlatformEmscriptenGetSystemInfo: function() {
+      var userAgent = navigator?.userAgent?.toLowerCase?.();
+      if (!userAgent) return 0;
+      var browser = 1 + ["chrom", "firefox", "safari"].findIndex(i => userAgent.includes(i));
+      var os = 1 + [/windows/, /linux|cros|android/, /iphone|ipad/, /mac os/].findIndex(i => i.test(userAgent));
+      return browser | (os << 16);
    },
 
    $EmscriptenSendCommand__deps: ["platform_emscripten_command_raise_flag"],
@@ -82,4 +160,4 @@ var LibraryPlatformEmscripten = {
 };
 
 autoAddDeps(LibraryPlatformEmscripten, '$RPE');
-mergeInto(LibraryManager.library, LibraryPlatformEmscripten);
+addToLibrary(LibraryPlatformEmscripten);
diff --git a/emscripten/library_rwebaudio.js b/emscripten/library_rwebaudio.js
index dfbca1e228d3..e7fa3dd76fb7 100644
--- a/emscripten/library_rwebaudio.js
+++ b/emscripten/library_rwebaudio.js
@@ -168,4 +168,4 @@ var LibraryRWebAudio = {
 };
 
 autoAddDeps(LibraryRWebAudio, '$RA');
-mergeInto(LibraryManager.library, LibraryRWebAudio);
+addToLibrary(LibraryRWebAudio);
diff --git a/emscripten/library_rwebcam.js b/emscripten/library_rwebcam.js
index 2aaef6e44d47..a3517dce0bc0 100644
--- a/emscripten/library_rwebcam.js
+++ b/emscripten/library_rwebcam.js
@@ -5,133 +5,273 @@ var LibraryRWebCam = {
       RETRO_CAMERA_BUFFER_OPENGL_TEXTURE: 0,
       RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER: 1,
       tmp: null,
-
       contexts: [],
-      counter: 0,
+      counter: 0
+   },
 
-      ready: function(data) {
-         return RWC.contexts[data].runMode == 2 && !RWC.contexts[data].videoElement.paused && RWC.contexts[data].videoElement.videoWidth != 0 && RWC.contexts[data].videoElement.videoHeight != 0;
-      }
+   $RWebCamReady: function(c) {
+      try {
+         /* try to start video it was paused */
+         if (RWC.contexts[c].videoElement.paused) RWC.contexts[c].videoElement.play();
+      } catch (e) {}
+      return RWC.contexts[c].cameraRunning && !RWC.contexts[c].videoElement.paused && RWC.contexts[c].videoElement.videoWidth != 0 && RWC.contexts[c].videoElement.videoHeight != 0;
    },
 
-   RWebCamInit__deps: ['malloc'],
-   RWebCamInit: function(caps1, caps2, width, height) {
-      if (!navigator) return 0;
-      if (!navigator.mediaDevices.getUserMedia) return 0;
+   $RWebCamInitBrowser__proxy: "sync",
+   $RWebCamInitBrowser: function(width, height, glTex, rawFb, debug, proxied) {
+      if (!navigator?.mediaDevices?.getUserMedia) return 0;
 
       var c = ++RWC.counter;
+      RWC.contexts[c] = {width: width, height: height, glTex: glTex, rawFb: rawFb, debug: debug, proxied: proxied, idealWidth: width, idealHeight: height};
+      return c;
+   },
+
+   RWebCamInit__deps: ["malloc", "$RWebCamInitBrowser"],
+   RWebCamInit: function(caps, width, height, debug) {
+      var glTex = Number(caps) & (1 << RWC.RETRO_CAMERA_BUFFER_OPENGL_TEXTURE);
+      var rawFb = Number(caps) & (1 << RWC.RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER);
+      var c = RWebCamInitBrowser(width, height, glTex, rawFb, debug, ENVIRONMENT_IS_PTHREAD);
+      if (!c) return 0;
+
+      if (debug) console.log("RWebCamInit", c);
 
-      RWC.contexts[c] = [];
+      if (ENVIRONMENT_IS_PTHREAD) {
+         RWC.contexts[c] = {};
+         RWC.contexts[c].debug = debug;
+         RWC.contexts[c].glTex = glTex;
+         RWC.contexts[c].rawFb = rawFb;
+      }
+
+      /* for getting/storing texture id in GL mode */
+      if (!RWC.tmp) RWC.tmp = _malloc(4);
+      return c;
+   },
+
+   $RWebCamFreeBrowser__proxy: "sync",
+   $RWebCamFreeBrowser: function(c) {
+      RWC.contexts[c] = null;
+   },
+
+   RWebCamFree__deps: ["$RWebCamFreeBrowser", "RWebCamStop"],
+   RWebCamFree: function(c) {
+      if (RWC.contexts[c].debug) console.log("RWebCamFree", c);
+      if (RWC.contexts[c].running) _RWebCamStop(c); /* need more checks in RA */
+      RWebCamFreeBrowser(c);
+      if (ENVIRONMENT_IS_PTHREAD) RWC.contexts[c] = null;
+   },
+
+   $RWebCamStartBrowser__proxy: "sync",
+   $RWebCamStartBrowser: function(c) {
       RWC.contexts[c].videoElement = document.createElement("video");
       RWC.contexts[c].videoElement.classList.add("retroarchWebcamVideo");
-      if (width !== 0 && height !== 0) {
-         RWC.contexts[c].videoElement.width = width;
-         RWC.contexts[c].videoElement.height = height;
+      if (RWC.contexts[c].debug) document.body.appendChild(RWC.contexts[c].videoElement);
+
+      if (RWC.contexts[c].rawFb || RWC.contexts[c].proxied) {
+         RWC.contexts[c].rawFbCanvas = document.createElement("canvas");
+         RWC.contexts[c].rawFbCanvas.classList.add("retroarchWebcamCanvas");
+         if (RWC.contexts[c].debug) document.body.appendChild(RWC.contexts[c].rawFbCanvas);
       }
-      RWC.contexts[c].runMode = 1;
-      RWC.contexts[c].glTex = caps1 & (1 << RWC.RETRO_CAMERA_BUFFER_OPENGL_TEXTURE);
-      RWC.contexts[c].rawFb = caps1 & (1 << RWC.RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER);
 
-      navigator.mediaDevices.getUserMedia({video: true, audio: false}).then(function(stream) {
+      var videoOpts = true;
+      if (RWC.contexts[c].idealWidth && RWC.contexts[c].idealHeight) {
+         /* save us some cropping/scaling, only honored by some browsers */
+         videoOpts = {width: RWC.contexts[c].idealWidth, height: RWC.contexts[c].idealHeight, aspectRatio: RWC.contexts[c].idealWidth / RWC.contexts[c].idealHeight};
+      }
+
+      navigator.mediaDevices.getUserMedia({audio: false, video: videoOpts}).then(function(stream) {
+         if (!RWC.contexts[c]?.running) {
+            /* too late */
+            for (var track of stream.getVideoTracks()) {
+               track.stop();
+            }
+            return;
+         }
          RWC.contexts[c].videoElement.autoplay = true;
          RWC.contexts[c].videoElement.srcObject = stream;
-         RWC.contexts[c].runMode = 2;
-      }).catch(function (err) {
-         console.log("webcam request failed", err);
-         RWC.runMode = 0;
+         RWC.contexts[c].cameraRunning = true;
+      }).catch(function(e) {
+         console.log("[rwebcam] webcam request failed", e);
       });
 
-      // for getting/storing texture id in GL mode
-      if (!RWC.tmp) RWC.tmp = _malloc(4);
-      return c;
-   },
-
-   RWebCamFree: function(data) {
-      RWC.contexts[data].videoElement.pause();
-      RWC.contexts[data].videoElement = null;
-      RWC.contexts[data] = null;
+      RWC.contexts[c].running = true;
    },
 
-   RWebCamStart__deps: ['glGenTextures', 'glBindTexture', 'glGetIntegerv', 'glTexParameteri', 'malloc'],
-   RWebCamStart: function(data) {
-      var ret = 0;
-      if (RWC.contexts[data].glTex) {
+   RWebCamStart__deps: ["glGenTextures", "glBindTexture", "glGetIntegerv", "glTexParameteri", "malloc", "$RWebCamStartBrowser"],
+   RWebCamStart: function(c) {
+      if (RWC.contexts[c].debug) console.log("RWebCamStart", c);
+      if (RWC.contexts[c].running) return;
+      if (RWC.contexts[c].glTex) {
          _glGenTextures(1, RWC.tmp);
-         RWC.contexts[data].glTexId = {{{ makeGetValue('RWC.tmp', '0', 'i32') }}};
-         if (RWC.contexts[data].glTexId !== 0) {
-            // save previous texture
+         RWC.contexts[c].glTexId = {{{ makeGetValue('RWC.tmp', 0, 'i32') }}};
+         if (RWC.contexts[c].glTexId !== 0) {
+            /* save previous texture */
             _glGetIntegerv(0x8069 /* GL_TEXTURE_BINDING_2D */, RWC.tmp);
-            var prev = {{{ makeGetValue('RWC.tmp', '0', 'i32') }}};
-            _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, RWC.contexts[data].glTexId);
+            var prev = {{{ makeGetValue('RWC.tmp', 0, 'i32') }}};
+            _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, RWC.contexts[c].glTexId);
             /* NPOT textures in WebGL must have these filters and clamping settings */
             _glTexParameteri(0x0DE1 /* GL_TEXTURE_2D */, 0x2800 /* GL_TEXTURE_MAG_FILTER */, 0x2601 /* GL_LINEAR */);
             _glTexParameteri(0x0DE1 /* GL_TEXTURE_2D */, 0x2801 /* GL_TEXTURE_MIN_FILTER */, 0x2601 /* GL_LINEAR */);
             _glTexParameteri(0x0DE1 /* GL_TEXTURE_2D */, 0x2802 /* GL_TEXTURE_WRAP_S */, 0x812F /* GL_CLAMP_TO_EDGE */);
             _glTexParameteri(0x0DE1 /* GL_TEXTURE_2D */, 0x2803 /*GL_TEXTURE_WRAP_T */, 0x812F /* GL_CLAMP_TO_EDGE */);
             _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, prev);
-            RWC.contexts[data].glFirstFrame = true;
-            ret = 1;
+            RWC.contexts[c].glFirstFrame = true;
          }
       }
-      if (RWC.contexts[data].rawFb) {
-         RWC.contexts[data].rawFbCanvas = document.createElement("canvas");
-         RWC.contexts[data].rawFbCanvas.classList.add("retroarchWebcamCanvas");
-         ret = 1;
-      }
 
-      return ret;
+      RWebCamStartBrowser(c);
+      RWC.contexts[c].running = true;
+      return 1;
    },
 
-   RWebCamStop__deps: ['glDeleteTextures', 'free'],
-   RWebCamStop: function(data) {
-      if (RWC.contexts[data].glTexId) {
-         _glDeleteTextures(1, RWC.contexts[data].glTexId);
+   $RWebCamStopBrowser__proxy: "sync",
+   $RWebCamStopBrowser: function(c) {
+      if (RWC.contexts[c].debug && RWC.contexts[c].rawFbCanvas) document.body.removeChild(RWC.contexts[c].rawFbCanvas);
+      RWC.contexts[c].rawFbCanvasCtx = null;
+      RWC.contexts[c].rawFbCanvas = null;
+      RWC.contexts[c].videoElement.pause();
+      if (RWC.contexts[c].cameraRunning) {
+         for (var track of RWC.contexts[c].videoElement.srcObject.getVideoTracks()) {
+            track.stop();
+         }
       }
+      if (RWC.contexts[c].debug) document.body.removeChild(RWC.contexts[c].videoElement);
+      RWC.contexts[c].videoElement = null;
+      RWC.contexts[c].running = false;
+   },
 
-      if (RWC.contexts[data].rawFbCanvas) {
-         if (RWC.contexts[data].rawBuffer) {
-            _free(RWC.contexts[data].rawBuffer);
-            RWC.contexts[data].rawBuffer = 0;
-            RWC.contexts[data].rawFbCanvasCtx = null
-         }
-         RWC.contexts[data].rawFbCanvas = null;
+   RWebCamStop__deps: ["free", "glDeleteTextures", "$RWebCamStopBrowser"],
+   RWebCamStop: function(c) {
+      if (RWC.contexts[c].debug) console.log("RWebCamStop", c);
+      if (!RWC.contexts[c].running) return;
+
+      if (RWC.contexts[c].glTexId) {
+         _glDeleteTextures(1, RWC.contexts[c].glTexId);
+      }
+
+      if (RWC.contexts[c].rawBuffer) {
+         _free(RWC.contexts[c].rawBuffer);
+         RWC.contexts[c].rawBuffer = 0;
       }
+
+      RWebCamStopBrowser(c);
+      RWC.contexts[c].running = false;
+   },
+
+   $RWebCamCheckDimensions: function(c) {
+      if (!RWC.contexts[c].width)  RWC.contexts[c].width  = RWC.contexts[c].videoElement.videoWidth;
+      if (!RWC.contexts[c].height) RWC.contexts[c].height = RWC.contexts[c].videoElement.videoHeight;
    },
 
-   RWebCamPoll__deps: ['glBindTexture', 'glGetIntegerv'],
-   RWebCamPoll: function(data, frame_raw_cb, frame_gl_cb) {
-      if (!RWC.ready(data)) return 0;
+   $RWebCamStoreDimensions__proxy: "sync",
+   $RWebCamStoreDimensions__deps: ["$RWebCamReady", "$RWebCamCheckDimensions"],
+   $RWebCamStoreDimensions: function(c, ptr) {
+      if (!RWebCamReady(c)) return 0;
+      RWebCamCheckDimensions(c);
+      {{{ makeSetValue('ptr', 0, 'RWC.contexts[c].width', 'i32') }}};
+      {{{ makeSetValue('ptr', 4, 'RWC.contexts[c].height', 'i32') }}};
+      return 1;
+   },
+
+   $RWebCamGetImageData__deps: ["$RWebCamReady"],
+   $RWebCamGetImageData: function(c) {
+      if (!RWebCamReady(c)) return 0;
+      if (!RWC.contexts[c].rawFbCanvasCtx) {
+         RWC.contexts[c].rawFbCanvas.width  = RWC.contexts[c].width;
+         RWC.contexts[c].rawFbCanvas.height = RWC.contexts[c].height;
+         RWC.contexts[c].rawFbCanvasCtx = RWC.contexts[c].rawFbCanvas.getContext("2d", {willReadFrequently: true});
+      }
+      /* crop to desired aspect ratio if necessary */
+      var oldAspect = RWC.contexts[c].videoElement.videoWidth / RWC.contexts[c].videoElement.videoHeight;
+      var newAspect = RWC.contexts[c].rawFbCanvas.width / RWC.contexts[c].rawFbCanvas.height;
+      var width = RWC.contexts[c].videoElement.videoWidth;
+      var height = RWC.contexts[c].videoElement.videoHeight;
+      var offsetX = 0;
+      var offsetY = 0;
+      if (oldAspect > newAspect) {
+         width = height * newAspect;
+         offsetX = (RWC.contexts[c].videoElement.videoWidth - width) / 2;
+      } else if (oldAspect < newAspect) {
+         height = width / newAspect;
+         offsetY = (RWC.contexts[c].videoElement.videoHeight - height) / 2;
+      }
+      RWC.contexts[c].rawFbCanvasCtx.drawImage(RWC.contexts[c].videoElement,
+         offsetX, offsetY, width, height,
+         0, 0, RWC.contexts[c].rawFbCanvas.width, RWC.contexts[c].rawFbCanvas.height);
+      return RWC.contexts[c].rawFbCanvasCtx.getImageData(0, 0, RWC.contexts[c].rawFbCanvas.width, RWC.contexts[c].rawFbCanvas.height).data;
+   },
+
+   $RWebCamStoreImageData__proxy: "sync",
+   $RWebCamStoreImageData__deps: ["$RWebCamGetImageData"],
+   $RWebCamStoreImageData: function(c, ptr) {
+      var data = RWebCamGetImageData(c);
+      if (!data) return 0;
+      HEAPU8.set(data, ptr);
+      return 1;
+   },
+
+   RWebCamPoll__deps: ["$RWebCamReady", "glBindTexture", "glGetIntegerv", "malloc", "free", "$RWebCamStoreDimensions", "$RWebCamCheckDimensions", "$RWebCamStoreImageData"],
+   RWebCamPoll: function(c, frame_raw_cb, frame_gl_cb) {
+      if (RWC.contexts[c].debug) console.log("RWebCamPoll", c, ENVIRONMENT_IS_PTHREAD, RWC.contexts[c].rawBuffer, RWC.contexts[c].running);
+      if (!RWC.contexts[c].running) return 0; /* need more checks in RA */
       var ret = 0;
 
-      if (RWC.contexts[data].glTexId !== 0 && frame_gl_cb !== 0) {
+      if ((RWC.contexts[c].rawFb && frame_raw_cb !== 0) || ENVIRONMENT_IS_PTHREAD) {
+         if (!RWC.contexts[c].rawBuffer) {
+            if (ENVIRONMENT_IS_PTHREAD) {
+               /* pull dimensions into this thread */
+               var dimensions = _malloc(8);
+               var res = RWebCamStoreDimensions(c, dimensions); /* also checks ready(c) */
+               if (!res) {
+                  _free(dimensions);
+                  return 0;
+               }
+               RWC.contexts[c].width  = {{{ makeGetValue('dimensions', 0, 'i32') }}};
+               RWC.contexts[c].height = {{{ makeGetValue('dimensions', 4, 'i32') }}};
+               _free(dimensions);
+               RWC.contexts[c].length = RWC.contexts[c].width * RWC.contexts[c].height * 4;
+            } else {
+               if (!RWebCamReady(c)) return 0;
+               RWebCamCheckDimensions(c);
+            }
+            RWC.contexts[c].rawBuffer = _malloc(RWC.contexts[c].width * RWC.contexts[c].height * 4 + 1);
+         }
+         /* store at +1 so we can read data as XRGB */
+         if (!RWebCamStoreImageData(c, RWC.contexts[c].rawBuffer + 1)) return 0;
+      }
+
+      if (RWC.contexts[c].glTexId !== 0 && frame_gl_cb !== 0) {
+         var imageSrcGL;
+         if (ENVIRONMENT_IS_PTHREAD) {
+            imageSrcGL = HEAPU8.subarray(RWC.contexts[c].rawBuffer + 1, RWC.contexts[c].rawBuffer + RWC.contexts[c].length + 1);
+         } else {
+            if (!RWebCamReady(c)) return 0;
+            imageSrcGL = RWC.contexts[c].videoElement;
+         }
+
          _glGetIntegerv(0x8069 /* GL_TEXTURE_BINDING_2D */, RWC.tmp);
-         var prev = {{{ makeGetValue('RWC.tmp', '0', 'i32') }}};
-         _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, RWC.contexts[data].glTexId);
-         if (RWC.contexts[data].glFirstFrame) {
-            Module.ctx.texImage2D(Module.ctx.TEXTURE_2D, 0, Module.ctx.RGB, Module.ctx.RGB, Module.ctx.UNSIGNED_BYTE, RWC.contexts[data].videoElement);
-            RWC.contexts[data].glFirstFrame = false;
+         var prev = {{{ makeGetValue('RWC.tmp', 0, 'i32') }}};
+         _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, RWC.contexts[c].glTexId);
+         if (RWC.contexts[c].glFirstFrame) {
+            if (ENVIRONMENT_IS_PTHREAD)
+               Module.ctx.texImage2D(Module.ctx.TEXTURE_2D, 0, Module.ctx.RGBA, RWC.contexts[c].width, RWC.contexts[c].height, 0, Module.ctx.RGBA, Module.ctx.UNSIGNED_BYTE, imageSrcGL);
+            else
+               Module.ctx.texImage2D(Module.ctx.TEXTURE_2D, 0, Module.ctx.RGB, Module.ctx.RGB, Module.ctx.UNSIGNED_BYTE, imageSrcGL);
+            RWC.contexts[c].glFirstFrame = false;
          } else {
-            Module.ctx.texSubImage2D(Module.ctx.TEXTURE_2D, 0, 0, 0, Module.ctx.RGB, Module.ctx.UNSIGNED_BYTE, RWC.contexts[data].videoElement);
+            if (ENVIRONMENT_IS_PTHREAD)
+               Module.ctx.texSubImage2D(Module.ctx.TEXTURE_2D, 0, 0, 0, RWC.contexts[c].width, RWC.contexts[c].height, Module.ctx.RGBA, Module.ctx.UNSIGNED_BYTE, imageSrcGL);
+            else
+               Module.ctx.texSubImage2D(Module.ctx.TEXTURE_2D, 0, 0, 0, Module.ctx.RGB, Module.ctx.UNSIGNED_BYTE, imageSrcGL);
          }
          _glBindTexture(0x0DE1 /* GL_TEXTURE_2D */, prev);
-         Runtime.dynCall('viii', frame_gl_cb, [RWC.contexts[data].glTexId, 0x0DE1 /* GL_TEXTURE_2D */, 0]);
 
+         {{{ makeDynCall('viip', 'frame_gl_cb') }}}(RWC.contexts[c].glTexId, 0x0DE1 /* GL_TEXTURE_2D */, 0);
          ret = 1;
       }
 
-      if (RWC.contexts[data].rawFbCanvas && frame_raw_cb !== 0)
+      if (RWC.contexts[c].rawFb && frame_raw_cb !== 0)
       {
-         if (!RWC.contexts[data].rawFbCanvasCtx) {
-            RWC.contexts[data].rawFbCanvas.width = RWC.contexts[data].videoElement.videoWidth;
-            RWC.contexts[data].rawFbCanvas.height = RWC.contexts[data].videoElement.videoHeight;
-            RWC.contexts[data].rawFbCanvasCtx = RWC.contexts[data].rawFbCanvas.getContext("2d");
-            RWC.contexts[data].rawBuffer = _malloc(RWC.contexts[data].videoElement.videoWidth * RWC.contexts[data].videoElement.videoHeight * 4);
-         }
-         RWC.contexts[data].rawFbCanvasCtx.drawImage(RWC.contexts[data].videoElement, 0, 0, RWC.contexts[data].rawFbCanvas.width, RWC.contexts[data].rawFbCanvas.height);
-         var image = RWC.contexts[data].rawFbCanvasCtx.getImageData(0, 0, RWC.contexts[data].videoElement.videoWidth, RWC.contexts[data].videoElement.videoHeight);
-         Module.HEAPU8.set(image.data, RWC.contexts[data].rawBuffer);
-         Runtime.dynCall('viiii', frame_raw_cb, [RWC.contexts[data].rawBuffer, RWC.contexts[data].videoElement.videoWidth, RWC.contexts[data].videoElement.videoHeight, RWC.contexts[data].videoElement.videoWidth * 4]);
-
+         {{{ makeDynCall('vpiii', 'frame_raw_cb') }}}(RWC.contexts[c].rawBuffer, RWC.contexts[c].width, RWC.contexts[c].height, RWC.contexts[c].width * 4);
          ret = 1;
       }
 
@@ -140,4 +280,4 @@ var LibraryRWebCam = {
 };
 
 autoAddDeps(LibraryRWebCam, '$RWC');
-mergeInto(LibraryManager.library, LibraryRWebCam);
+addToLibrary(LibraryRWebCam);
diff --git a/emscripten/template.html b/emscripten/template.html
deleted file mode 100644
index 1ee073247671..000000000000
--- a/emscripten/template.html
+++ /dev/null
@@ -1,134 +0,0 @@
-<!doctype html>
-
-<html lang="en">
-<head>
-   <meta charset="utf-8">
-   <title>RetroArch Web Player</title>
-   <script type="text/javascript" src="browserfs.js"></script>
-
-   <style>
-      .emscripten { padding-right: 0; margin-left: auto; margin-right: auto; display: block; }
-      textarea.emscripten { border: 0px; font-family: 'Share Tech Mono'; font-size: 12px; width: 100%; overflow:hide; resize:none; color:black; }
-      div.emscripten, h1 { text-align: left; }
-      div.canvas_border { background-color:gray; width:800px; height:600px; margin-left: auto; margin-right: auto; }
-      canvas.emscripten { border: 0px none; }
-   </style>
-</head>
-<body>
-   <hr/>
-   <div class="emscripten_border" id="canvas_div" style="display: none">
-      <canvas class="emscripten" id="canvas" tabindex="1" oncontextmenu="event.preventDefault()"></canvas>
-   </div>
-   <div class="emscripten emscripten_border" id="openrom">
-      <button id="btnLoad" onclick="document.getElementById('rom').click()">Upload Content</button>
-      <input style="display: none" type="file" id="rom" name="upload" onclick="document.getElementById('btnLoad').click();" onchange="runEmulator(event.target.files);" multiple />
-      <button id="btnStart" onclick="startRetroArch()">Start RetroArch</button>
-   </div>
-   <hr/>
-   <div class="emscripten">
-      <input type="checkbox" id="resize"><label for="resize">Resize canvas</label>
-      <input type="checkbox" id="pointerLock" checked><label for="pointerLock">Lock/hide mouse pointer</label>
-      <input type="button" value="Fullscreen" onclick="Module.requestFullScreen(document.getElementById('pointerLock').checked, document.getElementById('resize').checked)"><br>
-      <input type="checkbox" id="vsync"><label for="vsync" id="vsync-label">Enable V-sync (can only be done before loading game)</label><br>
-      <input type="checkbox" id="sdl2"><label for="sdl2" id="sdl2-label">Enable SDL2</label><br>
-      <input type="textbox" id="latency" size="3" maxlength="3" value="96"> <label for="latency" id="latency-label">Audio latency (ms) (increase if you hear pops at fullspeed, can only be done before loading game)</label>
-   </div>
-
-   <div class="emscripten" id="controls">
-      Controls:<br>
-      <br>
-      A button (OK in menu): X<br>
-      B button (Back in menu): Z<br>
-      X Button: S<br>
-      Y Button: A<br>
-      L Button: Q<br>
-      R Button: W<br>
-      D-pad: Arrow Keys<br>
-      Start Button: Enter<br>
-      Select Button: Shift<br>
-      Toggle Menu: F1<br>
-      Fast forward: Spacebar (toggle)<br>
-      Slow motion: E (hold)</br>
-      Save state: F2<br>
-      Load state: F4
-   </div>
-
-</body>
-</html>
-<script type='text/javascript'>
-   var count = 0;
-   function runEmulator(files)
-   {
-      count = files.length;
-      document.getElementById("openrom").innerHTML = '';
-      document.getElementById("openrom").style.display = 'none';
-      for (var i = 0; i < files.length; i++)
-      {
-         filereader = new FileReader();
-         filereader.file_name = files[i].name;
-         filereader.onload = function(){uploadData(this.result, this.file_name)};
-         filereader.readAsArrayBuffer(files[i]);
-    }
-  }
-  function startRetroArch()
-  {
-      Module.FS_createFolder('/', 'etc', true, true);
-      Module.FS_createFolder('/', 'config', true, true);
-      Module.FS_createFolder('/', 'content', true, true);
-      Module.FS_createFolder('/', 'savefiles', true, true);
-      Module.FS_createFolder('/', 'savestates', true, true);
-      var config = 'input_player1_select = shift\n';
-      var latency = parseInt(document.getElementById('latency').value, 10);
-      if (isNaN(latency)) latency = 96;
-      config += 'audio_latency = ' + latency + '\n'
-      if (document.getElementById('vsync').checked)
-         config += 'video_vsync = true\n';
-      else
-         config += 'video_vsync = false\n';
-      Module.FS_createDataFile('/etc', 'retroarch.cfg', config, true, true);
-      document.getElementById('canvas_div').style.display = 'block';
-      document.getElementById('vsync').disabled = true;
-      document.getElementById('vsync-label').style.color = 'gray';
-      document.getElementById('latency').disabled = true;
-      document.getElementById('latency-label').style.color = 'gray';
-      Module['callMain'](Module['arguments']);
-  }
-
-   function uploadData(data, name)
-   {
-      var dataView = new Uint8Array(data);
-      Module.FS_createDataFile('/', name, dataView, true, false);
-   }
-
-   var Module =
-   {
-      noInitialRun: true,
-      arguments: ["-v", "--menu"],
-      preRun: [],
-      postRun: [],
-      print: (function()
-      {
-         return function(text)
-         {
-            text = Array.prototype.slice.call(arguments).join(' ');
-            console.log(text);
-         };
-      })(),
-
-      printErr: function(text)
-      {
-         var errArgs = Array.prototype.slice.call(arguments).join(' ');
-         console.error(errArgs);
-      },
-      canvas: document.getElementById('canvas'),
-
-      totalDependencies: 0,
-      monitorRunDependencies: function(left)
-      {
-         this.totalDependencies = Math.max(this.totalDependencies, left);
-      }
-   };
-
-</script>
-<script type="text/javascript" src="browserfs.js"></script>
-<script type="text/javascript" src="gambatte.js"></script>
diff --git a/frontend/drivers/platform_emscripten.c b/frontend/drivers/platform_emscripten.c
index ff2cb3ec6f19..778622808fe4 100644
--- a/frontend/drivers/platform_emscripten.c
+++ b/frontend/drivers/platform_emscripten.c
@@ -17,6 +17,7 @@
 
 #include <emscripten/emscripten.h>
 #include <emscripten/html5.h>
+#include <emscripten/threading.h>
 #include <string.h>
 #include <malloc.h>
 #include <stdio.h>
@@ -60,7 +61,6 @@
 
 #ifdef PROXY_TO_PTHREAD
 #include <emscripten/wasm_worker.h>
-#include <emscripten/threading.h>
 #include <emscripten/proxying.h>
 #include <emscripten/atomic.h>
 #define PLATFORM_SETVAL(type, addr, val) emscripten_atomic_store_##type(addr, val)
@@ -73,10 +73,17 @@
 #include "platform_emscripten.h"
 
 void emscripten_mainloop(void);
+
+/* javascript library functions */
 void PlatformEmscriptenWatchCanvasSizeAndDpr(double *dpr);
 void PlatformEmscriptenWatchWindowVisibility(void);
 void PlatformEmscriptenPowerStateInit(void);
 void PlatformEmscriptenMemoryUsageInit(void);
+void PlatformEmscriptenWatchFullscreen(void);
+void PlatformEmscriptenGLContextEventInit(void);
+void PlatformEmscriptenSetCanvasSize(int width, int height);
+void PlatformEmscriptenSetWakeLock(bool state);
+uint32_t PlatformEmscriptenGetSystemInfo(void);
 
 typedef struct
 {
@@ -88,12 +95,18 @@ typedef struct
    uint64_t memory_used;
    uint64_t memory_limit;
    double device_pixel_ratio;
+   enum platform_emscripten_browser browser;
+   enum platform_emscripten_os os;
    int raf_interval;
    int canvas_width;
    int canvas_height;
    int power_state_discharge_time;
    float power_state_level;
    bool has_async_atomics;
+   bool enable_set_canvas_size;
+   bool disable_detect_enter_fullscreen;
+   bool fullscreen;
+   bool gl_context_lost;
    volatile bool power_state_charging;
    volatile bool power_state_supported;
    volatile bool window_hidden;
@@ -102,6 +115,22 @@ typedef struct
 
 static emscripten_platform_data_t *emscripten_platform_data = NULL;
 
+/* defined here since implementation is frontend dependent */
+void retro_sleep(unsigned msec)
+{
+#if defined(EMSCRIPTEN_ASYNCIFY) && !defined(HAVE_THREADS)
+   emscripten_sleep(msec);
+#elif defined(EMSCRIPTEN_ASYNCIFY)
+   if (emscripten_is_main_browser_thread())
+      emscripten_sleep(msec);
+   else
+      emscripten_thread_sleep(msec);
+#else
+   /* also works on main thread, but uses busy wait */
+   emscripten_thread_sleep(msec);
+#endif
+}
+
 /* begin exported functions */
 
 /* saves and states */
@@ -133,6 +162,11 @@ void cmd_undo_load_state(void)
 
 /* misc */
 
+void cmd_toggle_fullscreen(void)
+{
+   command_event(CMD_EVENT_FULLSCREEN_TOGGLE, NULL);
+}
+
 void cmd_take_screenshot(void)
 {
    command_event(CMD_EVENT_TAKE_SCREENSHOT, NULL);
@@ -232,7 +266,7 @@ void cmd_cheat_apply_cheats(void)
 
 /* javascript callbacks */
 
-void platform_emscripten_update_canvas_dimensions(int width, int height, double *dpr)
+void platform_emscripten_update_canvas_dimensions_cb(int width, int height, double *dpr)
 {
    printf("[INFO] Setting real canvas size: %d x %d\n", width, height);
    emscripten_set_canvas_element_size("#canvas", width, height);
@@ -243,14 +277,14 @@ void platform_emscripten_update_canvas_dimensions(int width, int height, double
    PLATFORM_SETVAL(f64, &emscripten_platform_data->device_pixel_ratio, *dpr);
 }
 
-void platform_emscripten_update_window_hidden(bool hidden)
+void platform_emscripten_update_window_hidden_cb(bool hidden)
 {
    if (!emscripten_platform_data)
       return;
    emscripten_platform_data->window_hidden = hidden;
 }
 
-void platform_emscripten_update_power_state(bool supported, int discharge_time, float level, bool charging)
+void platform_emscripten_update_power_state_cb(bool supported, int discharge_time, float level, bool charging)
 {
    if (!emscripten_platform_data)
       return;
@@ -260,12 +294,40 @@ void platform_emscripten_update_power_state(bool supported, int discharge_time,
    PLATFORM_SETVAL(f32, &emscripten_platform_data->power_state_level,          level);
 }
 
-void platform_emscripten_update_memory_usage(uint32_t used1, uint32_t used2, uint32_t limit1, uint32_t limit2)
+void platform_emscripten_update_memory_usage_cb(uint64_t used, uint64_t limit)
 {
    if (!emscripten_platform_data)
       return;
-   PLATFORM_SETVAL(u64, &emscripten_platform_data->memory_used,  used1 | ((uint64_t)used2 << 32));
-   PLATFORM_SETVAL(u64, &emscripten_platform_data->memory_limit, limit1 | ((uint64_t)limit2 << 32));
+   PLATFORM_SETVAL(u64, &emscripten_platform_data->memory_used,  used);
+   PLATFORM_SETVAL(u64, &emscripten_platform_data->memory_limit, limit);
+}
+
+static void fullscreen_update(void *data)
+{
+   command_event(CMD_EVENT_FULLSCREEN_TOGGLE, NULL);
+}
+
+void platform_emscripten_update_fullscreen_state_cb(bool state)
+{
+   if (state == emscripten_platform_data->fullscreen || (state && emscripten_platform_data->disable_detect_enter_fullscreen))
+      return;
+
+   emscripten_platform_data->fullscreen = state;
+   platform_emscripten_run_on_program_thread_async(fullscreen_update, NULL);
+}
+
+void platform_emscripten_gl_context_lost_cb(void)
+{
+   printf("[WARN] WebGL context lost!\n");
+   emscripten_platform_data->gl_context_lost = true;
+}
+
+void platform_emscripten_gl_context_restored_cb(void)
+{
+   printf("[INFO] WebGL context restored.\n");
+   emscripten_platform_data->gl_context_lost = false;
+   /* there might be a better way to do this, but for now this works */
+   command_event(CMD_EVENT_REINIT, NULL);
 }
 
 void platform_emscripten_command_raise_flag()
@@ -315,7 +377,7 @@ void platform_emscripten_command_reply(const char *msg, size_t len)
 
 size_t platform_emscripten_command_read(char **into, size_t max_len)
 {
-   if (!emscripten_platform_data || !emscripten_platform_data->command_flag)
+   if (!emscripten_platform_data->command_flag)
       return 0;
    return MAIN_THREAD_EM_ASM_INT({
       var next_command = RPE.command_queue.shift();
@@ -334,16 +396,12 @@ size_t platform_emscripten_command_read(char **into, size_t max_len)
 
 void platform_emscripten_get_canvas_size(int *width, int *height)
 {
-   if (!emscripten_platform_data)
-      goto error;
-
    *width  = PLATFORM_GETVAL(u32, &emscripten_platform_data->canvas_width);
    *height = PLATFORM_GETVAL(u32, &emscripten_platform_data->canvas_height);
 
    if (*width != 0 || *height != 0)
       return;
 
-error:
    *width  = 800;
    *height = 600;
    RARCH_ERR("[EMSCRIPTEN]: Could not get screen dimensions!\n");
@@ -354,6 +412,15 @@ double platform_emscripten_get_dpr(void)
    return PLATFORM_GETVAL(f64, &emscripten_platform_data->device_pixel_ratio);
 }
 
+unsigned platform_emscripten_get_min_sleep_ms(void)
+{
+   if (emscripten_platform_data->browser == PLATFORM_EMSCRIPTEN_BROWSER_FIREFOX &&
+       emscripten_platform_data->os      == PLATFORM_EMSCRIPTEN_OS_WINDOWS)
+      return 16;
+
+   return 5;
+}
+
 bool platform_emscripten_has_async_atomics(void)
 {
    return emscripten_platform_data->has_async_atomics;
@@ -366,7 +433,7 @@ bool platform_emscripten_is_window_hidden(void)
 
 bool platform_emscripten_should_drop_iter(void)
 {
-   return (emscripten_platform_data->window_hidden && emscripten_platform_data->raf_interval);
+   return (emscripten_platform_data->gl_context_lost || (emscripten_platform_data->window_hidden && emscripten_platform_data->raf_interval));
 }
 
 #ifdef PROXY_TO_PTHREAD
@@ -413,6 +480,58 @@ void platform_emscripten_set_main_loop_interval(int interval)
 #endif
 }
 
+void platform_emscripten_set_pointer_visibility(bool state)
+{
+   MAIN_THREAD_EM_ASM({
+      if ($0) {
+         Module.canvas.style.removeProperty("cursor");
+      } else {
+         Module.canvas.style.setProperty("cursor", "none");
+      }
+   }, state);
+}
+
+static void platform_emscripten_do_set_fullscreen_state(void *data)
+{
+   bool ran_user_cb = EM_ASM_INT({
+      var func = $0 ? "fullscreenEnter" : "fullscreenExit";
+      if (Module[func]) {
+         Module[func]();
+         return 1;
+      }
+      return 0;
+   }, emscripten_platform_data->fullscreen);
+   if (ran_user_cb)
+      return;
+
+   if (emscripten_platform_data->fullscreen)
+      emscripten_request_fullscreen("#canvas", false);
+   else
+      emscripten_exit_fullscreen();
+}
+
+void platform_emscripten_set_fullscreen_state(bool state)
+{
+   if (state == emscripten_platform_data->fullscreen)
+      return;
+
+   emscripten_platform_data->fullscreen = state;
+   platform_emscripten_run_on_browser_thread_sync(platform_emscripten_do_set_fullscreen_state, NULL);
+}
+
+void platform_emscripten_set_wake_lock(bool state)
+{
+   PlatformEmscriptenSetWakeLock(state);
+}
+
+void platform_emscripten_set_canvas_size(int width, int height)
+{
+   if (!emscripten_platform_data->enable_set_canvas_size)
+      return;
+
+   PlatformEmscriptenSetCanvasSize(width, height);
+}
+
 /* frontend driver impl */
 
 static void frontend_emscripten_get_env(int *argc, char *argv[],
@@ -575,7 +694,7 @@ static void platform_emscripten_mount_filesystems(void)
          path_parent_dir(parent, strlen(parent));
          if (!path_mkdir(parent))
          {
-            printf("mkdir error %d\n", errno);
+            printf("[OPFS] mkdir error %d\n", errno);
             abort();
          }
          free(parent);
@@ -704,6 +823,7 @@ static int thread_main(int argc, char *argv[])
    platform_emscripten_mount_filesystems();
 #endif
 
+   PlatformEmscriptenGLContextEventInit();
    emscripten_set_main_loop(emscripten_mainloop, 0, 0);
 #ifdef PROXY_TO_PTHREAD
    emscripten_set_main_loop_timing(EM_TIMING_SETIMMEDIATE, 0);
@@ -737,6 +857,7 @@ static void raf_signaler(void)
 int main(int argc, char *argv[])
 {
    int ret = 0;
+   uint32_t system_info;
 #ifdef PROXY_TO_PTHREAD
    pthread_attr_t attr;
    pthread_t thread;
@@ -744,14 +865,58 @@ int main(int argc, char *argv[])
    /* this never gets freed */
    emscripten_platform_data = (emscripten_platform_data_t *)calloc(1, sizeof(emscripten_platform_data_t));
 
+   system_info = PlatformEmscriptenGetSystemInfo();
+   emscripten_platform_data->browser = system_info & 0xFFFF;
+   emscripten_platform_data->os      = system_info >> 16;
+
+   emscripten_platform_data->enable_set_canvas_size = !!getenv("ENABLE_SET_CANVAS_SIZE");
+   emscripten_platform_data->disable_detect_enter_fullscreen = !!getenv("DISABLE_DETECT_ENTER_FULLSCREEN");
    emscripten_platform_data->has_async_atomics = EM_ASM_INT({
       return Atomics?.waitAsync?.toString().includes("[native code]");
    });
 
+   EM_ASM({
+      /* keyboard events won't work without the canvas being focused */
+      if (!Module.canvas.getAttribute("tabindex"))
+         Module.canvas.setAttribute("tabindex", "-1");
+      Module.canvas.focus();
+      Module.canvas.addEventListener("pointerdown", function() {
+         Module.canvas.focus();
+      }, false);
+
+      /* disable browser right click menu */
+      Module.canvas.addEventListener("contextmenu", function(e) {
+         e.preventDefault();
+      }, false);
+
+      /* background should be black */
+      Module.canvas.style.backgroundColor = "#000000";
+
+      /* border and padding may interfere with pointer event coordinates */
+      Module.canvas.style.setProperty("padding", "0px", "important");
+      Module.canvas.style.setProperty("border", "none", "important");
+
+      /* ensure canvas size is constrained by CSS, otherwise infinite resizing may occur */
+      if (window.getComputedStyle(Module.canvas).display == "inline") {
+         console.warn("[WARN] Canvas should not use display: inline!");
+         Module.canvas.style.display = "inline-block";
+      }
+      var oldWidth  = Module.canvas.clientWidth;
+      var oldHeight = Module.canvas.clientHeight;
+      Module.canvas.width  = 64;
+      Module.canvas.height = 64;
+      if (oldWidth != Module.canvas.clientWidth || oldHeight != Module.canvas.clientHeight) {
+         console.warn("[WARN] Canvas size should be set using CSS properties!");
+         Module.canvas.style.width  = oldWidth  + "px";
+         Module.canvas.style.height = oldHeight + "px";
+      }
+   });
+
    PlatformEmscriptenWatchCanvasSizeAndDpr(malloc(sizeof(double)));
    PlatformEmscriptenWatchWindowVisibility();
    PlatformEmscriptenPowerStateInit();
    PlatformEmscriptenMemoryUsageInit();
+   PlatformEmscriptenWatchFullscreen();
 
    emscripten_platform_data->raf_interval = 1;
 #ifdef PROXY_TO_PTHREAD
diff --git a/frontend/drivers/platform_emscripten.h b/frontend/drivers/platform_emscripten.h
index add2ad03c639..58dfa2794610 100644
--- a/frontend/drivers/platform_emscripten.h
+++ b/frontend/drivers/platform_emscripten.h
@@ -20,6 +20,23 @@
 
 #include <boolean.h>
 
+enum platform_emscripten_browser
+{
+   PLATFORM_EMSCRIPTEN_BROWSER_OTHER = 0,
+   PLATFORM_EMSCRIPTEN_BROWSER_CHROMIUM,
+   PLATFORM_EMSCRIPTEN_BROWSER_FIREFOX,
+   PLATFORM_EMSCRIPTEN_BROWSER_SAFARI
+};
+
+enum platform_emscripten_os
+{
+   PLATFORM_EMSCRIPTEN_OS_OTHER = 0,
+   PLATFORM_EMSCRIPTEN_OS_WINDOWS,
+   PLATFORM_EMSCRIPTEN_OS_LINUX,
+   PLATFORM_EMSCRIPTEN_OS_IOS,
+   PLATFORM_EMSCRIPTEN_OS_MACOS
+};
+
 /**
  * Synchronously run a function on the browser thread.
  *
@@ -77,6 +94,15 @@ void platform_emscripten_get_canvas_size(int *width, int *height);
  */
 double platform_emscripten_get_dpr(void);
 
+/**
+ * Get the minimum amount of time that setTimeout (retro_sleep on browser thread) can
+ * sleep for in a loop.
+ * This may vary between browsers: usually 5 ms, but much higher for firefox on windows.
+ *
+ * @return Minimum sleep in milliseconds.
+ */
+unsigned platform_emscripten_get_min_sleep_ms(void);
+
 /**
  * Check if the browser supports Atomics.waitAsync.
  *
@@ -128,4 +154,34 @@ void platform_emscripten_exit_fake_block(void);
  */
 void platform_emscripten_set_main_loop_interval(int interval);
 
+/**
+ * Hide or show the cursor.
+ *
+ * @param state True to show, false to hide.
+ */
+void platform_emscripten_set_pointer_visibility(bool state);
+
+/**
+ * Try to enter or exit fullscreen.
+ *
+ * @param state True to enter, false to exit.
+ */
+void platform_emscripten_set_fullscreen_state(bool state);
+
+/**
+ * Try to prevent the screen from dimming.
+ *
+ * @param state True to request, false to release.
+ */
+void platform_emscripten_set_wake_lock(bool state);
+
+/**
+ * Try to set the real screen dimensions of the canvas.
+ * Will only work if explicitly enabled by the embedder.
+ *
+ * @param width New width
+ * @param height New height
+ */
+void platform_emscripten_set_canvas_size(int width, int height);
+
 #endif
diff --git a/gfx/drivers_context/emscriptenegl_ctx.c b/gfx/drivers_context/emscriptenegl_ctx.c
index 2cfbfd066c4e..c12866202a08 100644
--- a/gfx/drivers_context/emscriptenegl_ctx.c
+++ b/gfx/drivers_context/emscriptenegl_ctx.c
@@ -63,16 +63,6 @@ static void gfx_ctx_emscripten_check_window(void *data, bool *quit,
    *quit   = false;
 }
 
-static void gfx_ctx_emscripten_swap_buffers(void *data)
-{
-#ifdef HAVE_EGL
-   /* Doesn't really do anything in WebGL, but it might 
-    * if we use WebGL workers in the future */
-   emscripten_ctx_data_t *emscripten = (emscripten_ctx_data_t*)data;
-   egl_swap_buffers(&emscripten->egl);
-#endif
-}
-
 static void gfx_ctx_emscripten_get_video_size(void *data,
       unsigned *width, unsigned *height)
 {
@@ -181,11 +171,11 @@ static void *gfx_ctx_emscripten_init(void *video_driver)
 }
 
 static bool gfx_ctx_emscripten_set_video_mode(void *data,
-      unsigned width, unsigned height,
-      bool fullscreen)
+      unsigned width, unsigned height, bool fullscreen)
 {
-   if (g_egl_inited)
-      return false;
+   platform_emscripten_set_fullscreen_state(fullscreen);
+   if (!fullscreen)
+      platform_emscripten_set_canvas_size(width, height);
 
    g_egl_inited = true;
    return true;
@@ -212,9 +202,20 @@ static void gfx_ctx_emscripten_input_driver(void *data,
    *input_data     = rwebinput;
 }
 
-static bool gfx_ctx_emscripten_has_focus(void *data) { return g_egl_inited; }
+static bool gfx_ctx_emscripten_has_focus(void *data) {
+   return g_egl_inited && !platform_emscripten_is_window_hidden();
+}
+
+static bool gfx_ctx_emscripten_suppress_screensaver(void *data, bool enable)
+{
+   platform_emscripten_set_wake_lock(enable);
+   return true;
+}
 
-static bool gfx_ctx_emscripten_suppress_screensaver(void *data, bool enable) { return false; }
+static void gfx_ctx_emscripten_show_mouse(void *data, bool state)
+{
+   platform_emscripten_set_pointer_visibility(state);
+}
 
 static float gfx_ctx_emscripten_translate_aspect(void *data,
       unsigned width, unsigned height) { return (float)width / height; }
@@ -259,11 +260,11 @@ const gfx_ctx_driver_t gfx_ctx_emscripten = {
    gfx_ctx_emscripten_translate_aspect,
    NULL, /* update_title */
    gfx_ctx_emscripten_check_window,
-   NULL, /* set_resize */
+   NULL, /* set_resize: no-op */
    gfx_ctx_emscripten_has_focus,
    gfx_ctx_emscripten_suppress_screensaver,
-   false,
-   gfx_ctx_emscripten_swap_buffers,
+   true, /* has_windowed */
+   NULL, /* swap_buffers: no-op */
    gfx_ctx_emscripten_input_driver,
 #ifdef HAVE_EGL
    egl_get_proc_address,
@@ -272,11 +273,11 @@ const gfx_ctx_driver_t gfx_ctx_emscripten = {
 #endif
    gfx_ctx_emscripten_init_egl_image_buffer,
    gfx_ctx_emscripten_write_egl_image,
-   NULL,
+   gfx_ctx_emscripten_show_mouse,
    "egl_emscripten",
    gfx_ctx_emscripten_get_flags,
    gfx_ctx_emscripten_set_flags,
    gfx_ctx_emscripten_bind_hw_render,
-   NULL,
-   NULL
+   NULL, /* get_context_data */
+   NULL  /* make_current */
 };
diff --git a/gfx/drivers_context/emscriptenwebgl_ctx.c b/gfx/drivers_context/emscriptenwebgl_ctx.c
index a2e659ba271b..9554f986f7d1 100644
--- a/gfx/drivers_context/emscriptenwebgl_ctx.c
+++ b/gfx/drivers_context/emscriptenwebgl_ctx.c
@@ -57,12 +57,6 @@ static void gfx_ctx_emscripten_webgl_check_window(void *data, bool *quit,
    *quit   = false;
 }
 
-/* https://github.com/emscripten-core/emscripten/issues/17816#issuecomment-1249719343 */
-static void gfx_ctx_emscripten_webgl_swap_buffers(void *data)
-{
-   (void)data;
-}
-
 static void gfx_ctx_emscripten_webgl_get_video_size(void *data,
       unsigned *width, unsigned *height)
 {
@@ -114,7 +108,7 @@ static void *gfx_ctx_emscripten_webgl_init(void *video_driver)
 
    EmscriptenWebGLContextAttributes attrs = {0};
    emscripten_webgl_init_context_attributes(&attrs);
-   attrs.alpha = false;
+   attrs.alpha = true;
    attrs.depth = true;
    attrs.stencil = true;
    attrs.antialias = false;
@@ -152,57 +146,24 @@ static void *gfx_ctx_emscripten_webgl_init(void *video_driver)
    return NULL;
 }
 
-static bool gfx_ctx_emscripten_webgl_set_canvas_size(int width, int height)
-{
-#ifdef NO_CANVAS_RESIZE
-   return false;
-#endif
-   double dpr = platform_emscripten_get_dpr();
-   EMSCRIPTEN_RESULT r = emscripten_set_element_css_size("#canvas", (double)width / dpr, (double)height / dpr);
-   RARCH_LOG("[EMSCRIPTEN/WebGL]: set canvas size to %d, %d\n", width, height);
-
-   if (r != EMSCRIPTEN_RESULT_SUCCESS)
-   {
-      RARCH_ERR("[EMSCRIPTEN/WebGL]: error resizing canvas: %d\n", r);
-      return false;
-   }
-   return true;
-}
-
 static bool gfx_ctx_emscripten_webgl_set_video_mode(void *data,
-      unsigned width, unsigned height,
-      bool fullscreen)
+      unsigned width, unsigned height, bool fullscreen)
 {
    emscripten_ctx_data_t *emscripten = (emscripten_ctx_data_t*)data;
    if (!emscripten || !emscripten->ctx)
       return false;
 
-   if (width != 0 && height != 0)
-   { 
-      if (!gfx_ctx_emscripten_webgl_set_canvas_size(width, height))
-         return false;
-   }
-   emscripten->fb_width = width;
-   emscripten->fb_height = height;
+   platform_emscripten_set_fullscreen_state(fullscreen);
+   if (!fullscreen)
+      platform_emscripten_set_canvas_size(width, height);
 
    return true;
 }
 
-bool gfx_ctx_emscripten_webgl_set_resize(void *data, unsigned width, unsigned height)
-{
-   emscripten_ctx_data_t *emscripten = (emscripten_ctx_data_t*)data;
-   if (!emscripten || !emscripten->ctx)
-      return false;
-   return gfx_ctx_emscripten_webgl_set_canvas_size(width, height);
-}
-
 static enum gfx_ctx_api gfx_ctx_emscripten_webgl_get_api(void *data) { return GFX_CTX_OPENGL_ES_API; }
 
 static bool gfx_ctx_emscripten_webgl_bind_api(void *data,
-      enum gfx_ctx_api api, unsigned major, unsigned minor)
-{
-   return true;
-}
+      enum gfx_ctx_api api, unsigned major, unsigned minor) { return true; }
 
 static void gfx_ctx_emscripten_webgl_input_driver(void *data,
       const char *name,
@@ -216,10 +177,19 @@ static void gfx_ctx_emscripten_webgl_input_driver(void *data,
 static bool gfx_ctx_emscripten_webgl_has_focus(void *data)
 {
    emscripten_ctx_data_t *emscripten = (emscripten_ctx_data_t*)data;
-   return emscripten && emscripten->ctx;
+   return emscripten && emscripten->ctx && !platform_emscripten_is_window_hidden();
 }
 
-static bool gfx_ctx_emscripten_webgl_suppress_screensaver(void *data, bool enable) { return false; }
+static bool gfx_ctx_emscripten_webgl_suppress_screensaver(void *data, bool enable)
+{
+   platform_emscripten_set_wake_lock(enable);
+   return true;
+}
+
+static void gfx_ctx_emscripten_webgl_show_mouse(void *data, bool state)
+{
+   platform_emscripten_set_pointer_visibility(state);
+}
 
 static float gfx_ctx_emscripten_webgl_translate_aspect(void *data,
       unsigned width, unsigned height) { return (float)width / height; }
@@ -260,16 +230,16 @@ const gfx_ctx_driver_t gfx_ctx_emscripten_webgl = {
    gfx_ctx_emscripten_webgl_translate_aspect,
    NULL, /* update_title */
    gfx_ctx_emscripten_webgl_check_window,
-   gfx_ctx_emscripten_webgl_set_resize,
+   NULL, /* set_resize: no-op */
    gfx_ctx_emscripten_webgl_has_focus,
    gfx_ctx_emscripten_webgl_suppress_screensaver,
-   false,
-   gfx_ctx_emscripten_webgl_swap_buffers,
+   true, /* has_windowed */
+   NULL, /* swap_buffers: no-op: https://github.com/emscripten-core/emscripten/issues/17816#issuecomment-1249719343 */
    gfx_ctx_emscripten_webgl_input_driver,
    gfx_ctx_emscripten_webgl_get_proc_address,
    NULL,
    NULL,
-   NULL,
+   gfx_ctx_emscripten_webgl_show_mouse,
    "webgl_emscripten",
    gfx_ctx_emscripten_webgl_get_flags,
    gfx_ctx_emscripten_webgl_set_flags,
diff --git a/input/drivers/rwebinput_input.c b/input/drivers/rwebinput_input.c
index 55982a056f88..ce6fdd17a798 100644
--- a/input/drivers/rwebinput_input.c
+++ b/input/drivers/rwebinput_input.c
@@ -87,11 +87,21 @@ typedef struct rwebinput_mouse_states
    uint8_t buttons;
 } rwebinput_mouse_state_t;
 
+typedef struct rwebinput_motion_states
+{
+   float x;
+   float y;
+   float z;
+   bool enabled;
+} rwebinput_motion_state_t;
+
 typedef struct rwebinput_input
 {
    rwebinput_mouse_state_t mouse;                /* double alignment */
    rwebinput_keyboard_event_queue_t keyboard;    /* ptr alignment */
    rwebinput_pointer_state_t pointer[MAX_TOUCH]; /* int alignment */
+   rwebinput_motion_state_t accelerometer;       /* float alignment */
+   rwebinput_motion_state_t gyroscope;           /* float alignment */
    unsigned pointer_count;
    bool keys[RETROK_LAST];
    bool pointerlock_active;
@@ -406,6 +416,26 @@ static EM_BOOL rwebinput_pointerlockchange_cb(int event_type,
    return EM_TRUE;
 }
 
+static EM_BOOL rwebinput_devicemotion_cb(int event_type,
+   const EmscriptenDeviceMotionEvent *device_motion_event, void *user_data)
+{
+   rwebinput_input_t *rwebinput = (rwebinput_input_t*)user_data;
+
+   /* TODO: what units does mGBA want? does something need to be changed on the core side? */
+   /* given in m/s^2 (inverted) */
+   rwebinput->accelerometer.x = device_motion_event->accelerationIncludingGravityX / -9.8;
+   rwebinput->accelerometer.y = device_motion_event->accelerationIncludingGravityY / -9.8;
+   rwebinput->accelerometer.z = device_motion_event->accelerationIncludingGravityZ / -9.8;
+   /* XYZ == BetaGammaAlpha according to W3C? in my testing it is AlphaBetaGamma... */
+   /* libretro wants radians/s but it is too fast in mGBA, see above comment */
+   /* given in degrees/s */
+   rwebinput->gyroscope.x = device_motion_event->rotationRateAlpha / 180;
+   rwebinput->gyroscope.y = device_motion_event->rotationRateBeta  / 180;
+   rwebinput->gyroscope.z = device_motion_event->rotationRateGamma / 180;
+
+   return EM_TRUE;
+}
+
 static void *rwebinput_input_init(const char *joypad_driver)
 {
    EMSCRIPTEN_RESULT r;
@@ -746,6 +776,84 @@ static void rwebinput_input_free(void *data)
    free(data);
 }
 
+static bool rwebinput_set_sensor_state(void *data, unsigned port,
+      enum retro_sensor_action action, unsigned rate)
+{
+   rwebinput_input_t *rwebinput = (rwebinput_input_t*)data;
+   EMSCRIPTEN_RESULT r;
+   bool old_state = rwebinput->accelerometer.enabled || rwebinput->gyroscope.enabled;
+   bool new_state;
+
+   switch (action)
+   {
+      case RETRO_SENSOR_ACCELEROMETER_ENABLE:
+         rwebinput->accelerometer.enabled = true;
+         break;
+      case RETRO_SENSOR_ACCELEROMETER_DISABLE:
+         rwebinput->accelerometer.enabled = false;
+         break;
+      case RETRO_SENSOR_GYROSCOPE_ENABLE:
+         rwebinput->gyroscope.enabled = true;
+         break;
+      case RETRO_SENSOR_GYROSCOPE_DISABLE:
+         rwebinput->gyroscope.enabled = false;
+         break;
+      case RETRO_SENSOR_ILLUMINANCE_ENABLE:
+      case RETRO_SENSOR_ILLUMINANCE_DISABLE:
+         return false; /* not supported (browsers removed support for now) */
+      default:
+         return false;
+   }
+
+   new_state = rwebinput->accelerometer.enabled || rwebinput->gyroscope.enabled;
+
+   if (!old_state && new_state)
+   {
+      r = emscripten_set_devicemotion_callback(rwebinput, false, rwebinput_devicemotion_cb);
+      if (r != EMSCRIPTEN_RESULT_SUCCESS)
+      {
+         RARCH_ERR(
+            "[EMSCRIPTEN/INPUT] failed to create devicemotion callback: %d\n", r);
+         return false;
+      }
+   }
+   else if (old_state && !new_state)
+   {
+      r = emscripten_set_devicemotion_callback(rwebinput, false, NULL);
+      if (r != EMSCRIPTEN_RESULT_SUCCESS)
+      {
+         RARCH_ERR(
+            "[EMSCRIPTEN/INPUT] failed to remove devicemotion callback: %d\n", r);
+         return false;
+      }
+   }
+
+   return true;
+}
+
+static float rwebinput_get_sensor_input(void *data, unsigned port, unsigned id)
+{
+   rwebinput_input_t *rwebinput = (rwebinput_input_t*)data;
+
+   switch (id)
+   {
+      case RETRO_SENSOR_ACCELEROMETER_X:
+         return rwebinput->accelerometer.x;
+      case RETRO_SENSOR_ACCELEROMETER_Y:
+         return rwebinput->accelerometer.y;
+      case RETRO_SENSOR_ACCELEROMETER_Z:
+         return rwebinput->accelerometer.z;
+      case RETRO_SENSOR_GYROSCOPE_X:
+         return rwebinput->gyroscope.x;
+      case RETRO_SENSOR_GYROSCOPE_Y:
+         return rwebinput->gyroscope.y;
+      case RETRO_SENSOR_GYROSCOPE_Z:
+         return rwebinput->gyroscope.z;
+   }
+
+   return 0.0f;
+}
+
 static void rwebinput_process_keyboard_events(
       rwebinput_input_t *rwebinput,
       rwebinput_keyboard_event_t *event)
@@ -840,8 +948,8 @@ input_driver_t input_rwebinput = {
    rwebinput_input_poll,
    rwebinput_input_state,
    rwebinput_input_free,
-   NULL,
-   NULL,
+   rwebinput_set_sensor_state,
+   rwebinput_get_sensor_input,
    rwebinput_get_capabilities,
    "rwebinput",
    rwebinput_grab_mouse,
diff --git a/input/drivers_joypad/rwebpad_joypad.c b/input/drivers_joypad/rwebpad_joypad.c
index 09e75258cca4..58bfbea023f2 100644
--- a/input/drivers_joypad/rwebpad_joypad.c
+++ b/input/drivers_joypad/rwebpad_joypad.c
@@ -30,9 +30,18 @@
 #define NUM_BUTTONS 64
 #define NUM_AXES 64
 
+struct rwebpad_joypad_rumble_state
+{
+   uint16_t pending_strong;
+   uint16_t pending_weak;
+   uint16_t strong;
+   uint16_t weak;
+};
+
 typedef struct
 {
    struct EmscriptenGamepadEvent pads[DEFAULT_MAX_PADS];
+   struct rwebpad_joypad_rumble_state rumble[DEFAULT_MAX_PADS];
    bool live_pads[DEFAULT_MAX_PADS];
 } rwebpad_joypad_data_t;
 
@@ -45,6 +54,9 @@ static EM_BOOL rwebpad_gamepad_cb(int event_type,
    unsigned vid = 1;
    unsigned pid = 1;
 
+   if (gamepad_event->index >= DEFAULT_MAX_PADS)
+      return EM_FALSE;
+
    switch (event_type)
    {
       case EMSCRIPTEN_EVENT_GAMEPADCONNECTED:
@@ -93,12 +105,11 @@ static void *rwebpad_joypad_init(void *data)
    return (void*)(-1);
 }
 
-static const char *rwebpad_joypad_name(unsigned pad)
+static const char *rwebpad_joypad_name(unsigned port)
 {
-   if (pad >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[pad]) {
+   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port])
       return "";
-   }
-   return rwebpad_joypad_data->pads[pad].id;
+   return rwebpad_joypad_data->pads[port].id;
 }
 
 static int32_t rwebpad_joypad_button(unsigned port, uint16_t joykey)
@@ -116,7 +127,8 @@ static void rwebpad_joypad_get_buttons(unsigned port, input_bits_t *state)
 {
    EmscriptenGamepadEvent gamepad_state;
    unsigned i;
-   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port]) {
+   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port])
+   {
       BIT256_CLEAR_ALL_PTR(state);
       return;
    }
@@ -151,9 +163,8 @@ static int16_t rwebpad_joypad_axis_state(
 
 static int16_t rwebpad_joypad_axis(unsigned port, uint32_t joyaxis)
 {
-   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port]) {
+   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port])
       return 0;
-   }
    return rwebpad_joypad_axis_state(&rwebpad_joypad_data->pads[port], port, joyaxis);
 }
 
@@ -192,14 +203,41 @@ static int16_t rwebpad_joypad_state(
    return ret;
 }
 
+static void rwebpad_joypad_update_rumble(unsigned port)
+{
+   bool rumble_old_state = rwebpad_joypad_data->rumble[port].strong || rwebpad_joypad_data->rumble[port].weak;
+   bool rumble_new_state = rwebpad_joypad_data->rumble[port].pending_strong || rwebpad_joypad_data->rumble[port].pending_weak;
+   rwebpad_joypad_data->rumble[port].strong = rwebpad_joypad_data->rumble[port].pending_strong;
+   rwebpad_joypad_data->rumble[port].weak   = rwebpad_joypad_data->rumble[port].pending_weak;
+
+   if (rumble_new_state)
+   {
+      EM_ASM({
+         try {
+            JSEvents?.lastGamepadState?.[$0]?.vibrationActuator?.playEffect?.("dual-rumble", {startDelay: 0, duration: 200, strongMagnitude: $1 / 65536, weakMagnitude: $2 / 65536});
+         } catch (e) {}
+      }, port, rwebpad_joypad_data->rumble[port].strong, rwebpad_joypad_data->rumble[port].weak);
+   }
+   else if (rumble_old_state && !rumble_new_state)
+   {
+      EM_ASM({
+         try {
+            JSEvents?.lastGamepadState?.[$0]?.vibrationActuator?.reset?.();
+         } catch (e) {}
+      }, port);
+   }
+}
+
 static void rwebpad_joypad_do_poll(void *data)
 {
-   int i;
+   unsigned port;
    emscripten_sample_gamepad_data();
-   for (i = 0; i < DEFAULT_MAX_PADS; i++)
+   for (port = 0; port < DEFAULT_MAX_PADS; port++)
    {
-      if (rwebpad_joypad_data->live_pads[i])
-         emscripten_get_gamepad_status(i, &rwebpad_joypad_data->pads[i]);
+      if (!rwebpad_joypad_data->live_pads[port])
+         continue;
+      emscripten_get_gamepad_status(port, &rwebpad_joypad_data->pads[port]);
+      rwebpad_joypad_update_rumble(port);
    }
 }
 
@@ -208,9 +246,29 @@ static void rwebpad_joypad_poll(void)
    platform_emscripten_run_on_browser_thread_sync(rwebpad_joypad_do_poll, NULL);
 }
 
-static bool rwebpad_joypad_query_pad(unsigned pad)
+static bool rwebpad_joypad_query_pad(unsigned port)
 {
-   return rwebpad_joypad_data->live_pads[pad];
+   return rwebpad_joypad_data->live_pads[port];
+}
+
+static bool rwebpad_joypad_set_rumble(unsigned port, enum retro_rumble_effect effect, uint16_t strength)
+{
+   if (port >= DEFAULT_MAX_PADS || !rwebpad_joypad_data->live_pads[port])
+      return false;
+
+   switch (effect)
+   {
+      case RETRO_RUMBLE_STRONG:
+         rwebpad_joypad_data->rumble[port].pending_strong = strength;
+         break;
+      case RETRO_RUMBLE_WEAK:
+         rwebpad_joypad_data->rumble[port].pending_weak = strength;
+         break;
+      default:
+         return false;
+   }
+
+   return true;
 }
 
 static void rwebpad_joypad_destroy(void) { }
@@ -224,7 +282,7 @@ input_device_driver_t rwebpad_joypad = {
    rwebpad_joypad_get_buttons,
    rwebpad_joypad_axis,
    rwebpad_joypad_poll,
-   NULL, /* set_rumble */
+   rwebpad_joypad_set_rumble,
    NULL, /* set_rumble_gain */
    NULL, /* set_sensor_state */
    NULL, /* get_sensor_input */
diff --git a/input/input_driver.h b/input/input_driver.h
index 8835a96505be..14f6f57a0dd1 100644
--- a/input/input_driver.h
+++ b/input/input_driver.h
@@ -96,6 +96,8 @@
 #define DEFAULT_MAX_PADS 4
 #elif defined(DINGUX)
 #define DEFAULT_MAX_PADS 2
+#elif defined(EMSCRIPTEN)
+#define DEFAULT_MAX_PADS 4
 #else
 #define DEFAULT_MAX_PADS 16
 #endif /* defined(ANDROID) */
diff --git a/libretro-common/include/retro_timers.h b/libretro-common/include/retro_timers.h
index 4882b483eac8..2ff7d76f3d87 100644
--- a/libretro-common/include/retro_timers.h
+++ b/libretro-common/include/retro_timers.h
@@ -39,9 +39,6 @@
 #include <psp2/kernel/threadmgr.h>
 #elif defined(_3DS)
 #include <3ds.h>
-#elif defined(EMSCRIPTEN)
-#include <emscripten/emscripten.h>
-#include <emscripten/threading.h>
 #else
 #include <time.h>
 #endif
@@ -101,18 +98,15 @@ static int nanosleepDOS(const struct timespec *rqtp, struct timespec *rmtp)
 #define retro_sleep(msec) (usleep(1000 * (msec)))
 #elif defined(WIIU)
 #define retro_sleep(msec) (OSSleepTicks(ms_to_ticks((msec))))
-#elif defined(EMSCRIPTEN) && defined(EMSCRIPTEN_ASYNCIFY) && !defined(HAVE_THREADS)
-#define retro_sleep(msec) (emscripten_sleep(msec))
-#elif defined(EMSCRIPTEN) && defined(EMSCRIPTEN_ASYNCIFY)
-static INLINE void retro_sleep(unsigned msec)
-{
-   if (emscripten_is_main_browser_thread())
-      emscripten_sleep(msec);
-   else
-      emscripten_thread_sleep(msec);
+#elif defined(__EMSCRIPTEN__)
+/* defined in frontend */
+#ifdef __cplusplus
+extern "C" {
+#endif
+void retro_sleep(unsigned msec);
+#ifdef __cplusplus
 }
-#elif defined(EMSCRIPTEN)
-#define retro_sleep(msec) (emscripten_thread_sleep(msec))
+#endif
 #else
 static INLINE void retro_sleep(unsigned msec)
 {
diff --git a/pkg/emscripten/libretro-thread/index.html b/pkg/emscripten/libretro-thread/index.html
index 211adf914af7..07856601df93 100644
--- a/pkg/emscripten/libretro-thread/index.html
+++ b/pkg/emscripten/libretro-thread/index.html
@@ -194,7 +194,7 @@ <h3><b><span class="fa fa-bars"></span>&nbsp; Quick Menu</b></h3>
 			</div>
 		</div>
 		<div class="webplayer-container">
-			<canvas class="webplayer" id="canvas" tabindex="1"></canvas>
+			<canvas class="webplayer" id="canvas"></canvas>
 			<div id="webplayer-preview"></div>
 		</div>
 		<script src="libretro.js"></script>
diff --git a/pkg/emscripten/libretro-thread/libretro.css b/pkg/emscripten/libretro-thread/libretro.css
index cf08d0993695..385f4277d0fa 100644
--- a/pkg/emscripten/libretro-thread/libretro.css
+++ b/pkg/emscripten/libretro-thread/libretro.css
@@ -339,6 +339,9 @@ label {
 	left: 50%;
 	top: 50%;
 	transform: translate(-50%, -50%);
+	left: round(50%, 1px);
+	top: round(50%, 1px);
+	transform: translate(round(-50%, 1px), round(-50%, 1px));
 	max-height: calc(100vh - 40px);
 	max-width: calc(100vw - 40px);
 	width: 750px;
@@ -388,6 +391,19 @@ label {
 	margin-top: 0px;
 }
 
+/* debug */
+
+.retroarchWebcamVideo, .retroarchWebcamCanvas {
+	position: absolute;
+	left: 0px;
+	top: 0px;
+	z-index: 5000;
+}
+
+.retroarchWebcamCanvas {
+	top: 112px;
+}
+
 /* mobile */
 
 @media only screen and (max-width: 720px) {
diff --git a/pkg/emscripten/libretro-thread/libretro.js b/pkg/emscripten/libretro-thread/libretro.js
index 324c4cc44a8e..7ca538b7eced 100644
--- a/pkg/emscripten/libretro-thread/libretro.js
+++ b/pkg/emscripten/libretro-thread/libretro.js
@@ -170,6 +170,10 @@ var Module = {
 			module.ENV["OPFS_MOUNT"] = "/home/web_user";
 		}
 	],
+	locateFile: function(path, prefix) {
+		if (path.endsWith(".js")) return typeof this.mainScriptUrlOrBlob == "string" ? this.mainScriptUrlOrBlob : URL.createObjectURL(this.mainScriptUrlOrBlob);
+		return path;
+	},
 	onRuntimeInitialized: function() {
 		appInitialized();
 	},
@@ -313,19 +317,15 @@ function startRetroArch() {
 
 	btnMenu.classList.remove("disabled");
 	btnMenu.addEventListener("click", function() {
-		Module._cmd_toggle_menu();
+		Module.retroArchSend("MENU_TOGGLE");
 	});
 
 	btnFullscreen.classList.remove("disabled");
 	btnFullscreen.addEventListener("click", function() {
-		Module.requestFullscreen(false);
+		Module.retroArchSend("FULLSCREEN_TOGGLE");
 	});
 
-	// ensure the canvas is focused so that keyboard events work
-	Module.canvas.focus();
-	Module.canvas.addEventListener("pointerdown", function() {
-		Module.canvas.focus();
-	}, false);
+	// refocus the canvas so that keyboard events work
 	menuBar.addEventListener("pointerdown", function() {
 		setTimeout(function() {
 			Module.canvas.focus();
diff --git a/pkg/emscripten/libretro/index.html b/pkg/emscripten/libretro/index.html
index 31cd1bda9b80..07e6803a1d61 100644
--- a/pkg/emscripten/libretro/index.html
+++ b/pkg/emscripten/libretro/index.html
@@ -188,7 +188,7 @@ <h3><b><span class="fa fa-bars"></span> Quick Menu</b></h3>
                <span class="fa fa-chevron-down" id="icnShowMenu"></span> <span class="sr-only">Show Top Navigation</span>
                </button>
             </div>
-            <canvas class="webplayer" id="canvas" tabindex="1" oncontextmenu="event.preventDefault()" style="display: none"></canvas>
+            <canvas class="webplayer" id="canvas" style="display: none"></canvas>
             <img class="webplayer-preview img-fluid" src="media/canvas.png" width="960" height="720" alt="RetroArch Logo">
          </div>
       </div>
diff --git a/pkg/emscripten/libretro/libretro.js b/pkg/emscripten/libretro/libretro.js
index d4dcb946fb20..aa252ab5e000 100644
--- a/pkg/emscripten/libretro/libretro.js
+++ b/pkg/emscripten/libretro/libretro.js
@@ -187,18 +187,14 @@ function startRetroArch() {
       selectFiles(e.target.files);
    });
    $('#btnMenu').removeClass("disabled").removeAttr("disabled").click(function() {
-      Module._cmd_toggle_menu();
+      Module.retroArchSend("MENU_TOGGLE");
       Module.canvas.focus();
    });
    $('#btnFullscreen').removeClass("disabled").removeAttr("disabled").click(function() {
-      Module.requestFullscreen(false);
+      Module.retroArchSend("FULLSCREEN_TOGGLE");
       Module.canvas.focus();
    });
 
-   Module.canvas.focus();
-   Module.canvas.addEventListener("pointerdown", function() {
-      Module.canvas.focus();
-   }, false);
    Module.callMain(Module.arguments);
 }
 
diff --git a/retroarch.c b/retroarch.c
index e23408e7d5ad..65f455c8fd60 100644
--- a/retroarch.c
+++ b/retroarch.c
@@ -6029,7 +6029,11 @@ void emscripten_mainloop(void)
    bool runloop_is_slowmotion             = (runloop_flags & RUNLOOP_FLAG_SLOWMOTION) ? true : false;
    bool runloop_is_paused                 = (runloop_flags & RUNLOOP_FLAG_PAUSED)     ? true : false;
 
-   /* firefox especially seems to bug without this */
+   /* Prevents the program from running in any of the following conditions:
+    * 1. requestAnimationFrame is being used and the window is not visible.
+    * Firefox likes to call requestAnimationFrame at 1 FPS when the window isn't focused,
+    * we want to avoid this.
+    * 2. The GL context is lost and hasn't been recovered yet. */
    if (platform_emscripten_should_drop_iter())
       return;
 


Reviews:
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:28:07Z
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:28:41Z
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:29:42Z
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:33:45Z
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:36:49Z
user: login: JoeOsborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:37:12Z
user: login: JoeOsborn
body: This looks pretty good.  Some lines were commented out probably temporarily in `pkg/emscripten/libretro/libretro.js`, and m4xw had a concern about get_min_sleep_ms since chrome evidently allows for <1ms sleeps.  I don't know if they followed up with you individually about that.

In the future, it would be easier to review larger numbers of more independent PRs (e.g. one for webcam, one for motion input, one for fullscreen, etc), but I know how things go sometimes.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:39:20Z
user: login: BinBashBanana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:42:40Z
user: login: BinBashBanana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:50:44Z
user: login: BinBashBanana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:50:56Z
user: login: JoeOsborn
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:12:05Z

Review comments:
diff_hunk: @@ -123,7 +126,7 @@ INITIAL_HEAP ?= 134217728
 
 OBJDIR := obj-emscripten
 
-EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_take_screenshot,\
+EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_toggle_fullscreen,_cmd_take_screenshot,\
path: Makefile.emscripten
user: login: JoeOsborn
body: I'm suspicious of all the `_cmd_` functions since the thread they're called on won't be the main thread.  This isn't the time to do it, but I'd like to move all of those to the emscripten stdin-alike cmd interface.
created_at: 2025-05-08T17:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,21 +300,11 @@ all: $(TARGET)
 $(libretro_new): ;
 
 mv_libretro:
-	mv -f $(libretro) $(libretro_new) || true
+	$(Q)mv -f $(libretro) $(libretro_new) || true
 
-# until emscripten adds something like WASM_WORKERS=2 but for audio worklets, DIY.
-ifeq ($(HAVE_AUDIOWORKLET), 1)
 $(TARGET): $(RARCH_OBJ) $(libretro_new) mv_libretro
path: Makefile.emscripten
user: login: JoeOsborn
body: nice
created_at: 2025-05-08T17:28:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,27 +40,112 @@ var LibraryPlatformEmscripten = {
       }, false);
    },
 
-   PlatformEmscriptenWatchWindowVisibility__deps: ["platform_emscripten_update_window_hidden"],
+   PlatformEmscriptenWatchWindowVisibility__deps: ["platform_emscripten_update_window_hidden_cb"],
    PlatformEmscriptenWatchWindowVisibility: function() {
       document.addEventListener("visibilitychange", function() {
-         _platform_emscripten_update_window_hidden(document.visibilityState == "hidden");
+         _platform_emscripten_update_window_hidden_cb(document.visibilityState == "hidden");
       }, false);
    },
 
-   PlatformEmscriptenPowerStateInit__deps: ["platform_emscripten_update_power_state"],
+   $PlatformEmscriptenPowerStateChange__deps: ["platform_emscripten_update_power_state_cb"],
+   $PlatformEmscriptenPowerStateChange: function(e) {
+      _platform_emscripten_update_power_state_cb(true, Number.isFinite(e.target.dischargingTime) ? e.target.dischargingTime : 0x7FFFFFFF, e.target.level, e.target.charging);
+   },
+
+   PlatformEmscriptenPowerStateInit__deps: ["$PlatformEmscriptenPowerStateChange"],
    PlatformEmscriptenPowerStateInit: function() {
       if (!navigator.getBattery) return;
       navigator.getBattery().then(function(battery) {
-         battery.addEventListener("chargingchange", RPE.powerStateChange);
-         battery.addEventListener("levelchange", RPE.powerStateChange);
-         RPE.powerStateChange({target: battery});
+         battery.addEventListener("chargingchange", PlatformEmscriptenPowerStateChange);
+         battery.addEventListener("levelchange", PlatformEmscriptenPowerStateChange);
+         PlatformEmscriptenPowerStateChange({target: battery});
       });
    },
 
-   PlatformEmscriptenMemoryUsageInit__deps: ["platform_emscripten_update_memory_usage"],
+   $PlatformEmscriptenUpdateMemoryUsage__deps: ["platform_emscripten_update_memory_usage_cb"],
+   $PlatformEmscriptenUpdateMemoryUsage: function() {
+      // unfortunately this will be inaccurate in threaded (worker) builds
+      _platform_emscripten_update_memory_usage_cb(BigInt(performance.memory.usedJSHeapSize || 0), BigInt(performance.memory.jsHeapSizeLimit || 0));
+      setTimeout(PlatformEmscriptenUpdateMemoryUsage, 5000);
+   },
+
+   PlatformEmscriptenMemoryUsageInit__deps: ["$PlatformEmscriptenUpdateMemoryUsage"],
    PlatformEmscriptenMemoryUsageInit: function() {
       if (!performance.memory) return;
-      RPE.updateMemoryUsage();
+      PlatformEmscriptenUpdateMemoryUsage();
+   },
+
+   PlatformEmscriptenWatchFullscreen__deps: ["platform_emscripten_update_fullscreen_state_cb"],
+   PlatformEmscriptenWatchFullscreen: function() {
+      document.addEventListener("fullscreenchange", function() {
+         _platform_emscripten_update_fullscreen_state_cb(!!document.fullscreenElement);
+      }, false);
+   },
+
+   PlatformEmscriptenGLContextEventInit__deps: ["platform_emscripten_gl_context_lost_cb", "platform_emscripten_gl_context_restored_cb"],
+   PlatformEmscriptenGLContextEventInit: function() {
+      Module.canvas.addEventListener("webglcontextlost", function(e) {
path: emscripten/library_platform_emscripten.js
user: login: JoeOsborn
body: this is great
created_at: 2025-05-08T17:29:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -737,21 +857,63 @@ static void raf_signaler(void)
 int main(int argc, char *argv[])
 {
    int ret = 0;
+   uint32_t system_info;
 #ifdef PROXY_TO_PTHREAD
    pthread_attr_t attr;
    pthread_t thread;
 #endif
    /* this never gets freed */
    emscripten_platform_data = (emscripten_platform_data_t *)calloc(1, sizeof(emscripten_platform_data_t));
 
+   system_info = PlatformEmscriptenGetSystemInfo();
+   emscripten_platform_data->browser = system_info & 0xFFFF;
+   emscripten_platform_data->os      = system_info >> 16;
+
+   emscripten_platform_data->enable_set_canvas_size = !!getenv("ENABLE_SET_CANVAS_SIZE");
+   emscripten_platform_data->disable_detect_enter_fullscreen = !!getenv("DISABLE_DETECT_ENTER_FULLSCREEN");
    emscripten_platform_data->has_async_atomics = EM_ASM_INT({
       return Atomics?.waitAsync?.toString().includes("[native code]");
    });
 
+   EM_ASM({
+      /* keyboard events won't work without the canvas being focused */
+      if (!Module.canvas.getAttribute("tabindex"))
+         Module.canvas.setAttribute("tabindex", "-1");
+      Module.canvas.focus();
+      Module.canvas.addEventListener("pointerdown", function() {
+         Module.canvas.focus();
+      }, false);
+
+      /* disable browser right click menu */
+      Module.canvas.addEventListener("contextmenu", function(e) {
+         e.preventDefault();
+      }, false);
+
+      /* background should be black */
+      Module.canvas.style.backgroundColor = "#000000";
+
+      /* border and padding may interfere with pointer event coordinates */
+      Module.canvas.style.setProperty("padding", "0px", "important");
+      Module.canvas.style.setProperty("border", "none", "important");
+
+      /* ensure canvas size is constrained by CSS, otherwise infinite resizing may occur */
+      if (window.getComputedStyle(Module.canvas).display == "inline")
path: frontend/drivers/platform_emscripten.c
user: login: JoeOsborn
body: nice, this is a big improvement; but maybe print a warning message too?
created_at: 2025-05-08T17:33:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -153,12 +153,12 @@ function setupFileSystem(backend) {
    // create a ZipFS filesystem for the bundled data
    var zipfs = new BrowserFS.FileSystem.ZipFS(zipTOC);
    // create an XmlHttpRequest filesystem for core assets
-   var xfs = new BrowserFS.FileSystem.XmlHttpRequest(".index-xhr", "assets/cores/");
+   //var xfs = new BrowserFS.FileSystem.XmlHttpRequest(".index-xhr", "assets/cores/");
path: pkg/emscripten/libretro/libretro.js
user: login: JoeOsborn
body: Why were these commented out?  I think the deployment bug behind this problem was fixed.
created_at: 2025-05-08T17:36:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -191,14 +191,10 @@ function startRetroArch() {
       Module.canvas.focus();
    });
    $('#btnFullscreen').removeClass("disabled").removeAttr("disabled").click(function() {
-      Module.requestFullscreen(false);
+      Module._cmd_toggle_fullscreen();
path: pkg/emscripten/libretro/libretro.js
user: login: JoeOsborn
body: Should this be `Module.retroArchSend("FULLSCREEN_TOGGLE");`?
created_at: 2025-05-08T17:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -123,7 +126,7 @@ INITIAL_HEAP ?= 134217728
 
 OBJDIR := obj-emscripten
 
-EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_take_screenshot,\
+EXPORTED_FUNCTIONS = _main,_malloc,_free,_cmd_savefiles,_cmd_save_state,_cmd_load_state,_cmd_undo_save_state,_cmd_undo_load_state,_cmd_toggle_fullscreen,_cmd_take_screenshot,\
path: Makefile.emscripten
user: login: BinBashBanana
body: I added this mostly for non-proxy-to-pthread builds, also these can (potentially) cause issues if they're called during an `emscripten_sleep` (for full asyncify builds)
created_at: 2025-05-08T17:42:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -153,12 +153,12 @@ function setupFileSystem(backend) {
    // create a ZipFS filesystem for the bundled data
    var zipfs = new BrowserFS.FileSystem.ZipFS(zipTOC);
    // create an XmlHttpRequest filesystem for core assets
-   var xfs = new BrowserFS.FileSystem.XmlHttpRequest(".index-xhr", "assets/cores/");
+   //var xfs = new BrowserFS.FileSystem.XmlHttpRequest(".index-xhr", "assets/cores/");
path: pkg/emscripten/libretro/libretro.js
user: login: BinBashBanana
body: Will revert this change
created_at: 2025-05-08T17:50:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -191,14 +191,10 @@ function startRetroArch() {
       Module.canvas.focus();
    });
    $('#btnFullscreen').removeClass("disabled").removeAttr("disabled").click(function() {
-      Module.requestFullscreen(false);
+      Module._cmd_toggle_fullscreen();
path: pkg/emscripten/libretro/libretro.js
user: login: BinBashBanana
body: Yes
created_at: 2025-05-08T17:50:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: BinBashBanana
created_at: 2025-05-08T18:26:27Z
body: I was initially using `get_min_sleep_ms` to see if AudioWorklet should force busy-wait if the latency was too small, which was a bit more of an issue in firefox on windows, but it ended up not making much of a difference (and the underrun issue only occurs with really small buffers, <24 ms latency). Chrome (can) sleep for 1 ms consistently, but it must use a setTimeout on top of a setImmediate (postMessage), which emscripten doesn't do currently. Otherwise the delay is around 5 ms.

And sorry about the large PR, it always starts as one thing and then turns into all these other things. I'll work on that ;)
author_association: CONTRIBUTOR
user: login: JoeOsborn
created_at: 2025-05-08T19:11:57Z
body: I am also guilty of large PRs (: this looks great, thank you!
author_association: CONTRIBUTOR