Repository: rommapp/romm
Pull request #1890: refactor: Assets filesystem path
Author: login: zurdi15
Labels: name: python
description: Pull requests that update Python code
Created: 2025-05-14T17:36:39Z | Merged: 2025-05-16T17:15:08Z

<!-- trunk-ignore-all(markdownlint/MD041) -->
<!-- trunk-ignore-all(markdownlint/MD033) -->

**Description**
This PR makes a change in the assets path where now we use the ``rom id`` in the path where the ``saves``, ``states`` and ``screenshots`` are stored:

 - Before: ``assets/users/{user_id}/(saves|states|screenshots)/{platform_slug}/[{emulator}]/{save_filename}``
 - After: ``assets/users/{user_id}/(saves|states|screenshots)/{platform_slug}/{rom_id}/[{emulator}]/{save_filename}``

This fix the overwriting issue where an asset was replaced by the new uploaded one if both had the same name, for different games of the same platform.

**Checklist**
<sup>Please check all that apply.</sup>

- [x] I've tested the changes locally
- [x] I've updated relevant comments
- [x] I've assigned reviewers for this PR
- [ ] I've added unit tests that cover the changes

Closes #1840


Linked issue #1840: [Bug] Save files over-written
**RomM version**
3.8.3

**Describe the bug**
Noticed a problem with Romm save system. If you upload two save files with the same name in the save tab, then the first file will be overwritten by the second one in the file system. All saves for a platform are stored in the same folder, which is illogical. User lost 20 Windows game saves thinking they were safe with the Romm save manager. But then user realized that Romm overwrote 29 of my saves with the 30th one that had the same name save.zip. Each archive was uploaded to a different game.

**To Reproduce**
Steps to reproduce the behavior:

Upload save from the WebUI

**Expected behavior**
Expected to keep save files seperated

the thing if that if you upload a save file to one game, lets say mysave.zip and then you upload a savefile to other game of the same platform, with that same name mysave.zip, it will overwrite the save file of the first game

Reported by Users Gargonuta and SnareGhost
Linked issue comments: user: login: demonpig
created_at: 2025-04-20T03:19:14Z
body: I'm dealing with another issue regarding user permissions and save files. But I saw this issue and figured I could provide some evidence.

Below is the output of the `find` command where its showing everything under the `assets/users` directory. Save files will be under the `assets/users/$USER_ID/saves/$PLATFORM` directory. The destination file name will be the same as the source.

```
romm@desktop1:~/romm/assets/users$ find .
.
./557365723a31
./557365723a31/states
./557365723a31/states/n64
./557365723a31/states/n64/mupen64plus_next
./557365723a31/states/n64/mupen64plus_next/Super Mario 64 (USA) [2025-04-06 15-06-32-444].state
./557365723a31/states/gb
./557365723a31/states/gb/gambatte
./557365723a31/states/gb/gambatte/Pokemon - Yellow Version - Special Pikachu Edition (USA, Europe) (GBC,SGB Enhanced) [2025-04-09 02-50-00-080].state
...
./557365723a31/saves
./557365723a31/saves/gb
./557365723a33
./557365723a33/saves
./557365723a33/saves/gb
./557365723a33/saves/gb/Pokemon - Yellow Version - Special Pikachu Edition (USA, Europe) (GBC,SGB Enhanced).srm
``` 

The interesting thing is that state files, that are generated via Save-Exit process in EmulatorJS, will be have a timestamp in the name.
author_association: NONE
user: login: gantoine
created_at: 2025-06-10T03:30:55Z
body: This is fixed as of `3.10.1`!
author_association: MEMBER

Diff:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 4c2d55129d..0331bee6a4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,19 @@
   "python.testing.pytestArgs": ["."],
   "python.testing.unittestEnabled": false,
   "python.testing.pytestEnabled": true,
-  "postman.settings.dotenv-detection-notification-visibility": false
+  "sqltools.connections": [
+    {
+      "mysqlOptions": {
+        "authProtocol": "default",
+        "enableSsl": "Disabled"
+      },
+      "previewLimit": 50,
+      "server": "localhost",
+      "port": 3306,
+      "driver": "MariaDB",
+      "name": "romm (dev)",
+      "database": "romm",
+      "username": "root"
+    }
+  ]
 }
diff --git a/backend/alembic/versions/0040_migrate_assets_paths.py b/backend/alembic/versions/0040_migrate_assets_paths.py
new file mode 100644
index 0000000000..1cda51aedd
--- /dev/null
+++ b/backend/alembic/versions/0040_migrate_assets_paths.py
@@ -0,0 +1,117 @@
+"""migrate asset file paths to include rom_id
+
+Revision ID: 0040_migrate_assets_paths
+Revises: 0039_add_retro_achievements
+Create Date: 2025-05-14 18:10:23.522345
+"""
+
+import os
+
+from alembic import op
+from config import ASSETS_BASE_PATH
+from logger.logger import log
+from sqlalchemy.sql import text
+from utils.database import is_postgresql
+
+# revision identifiers, used by Alembic.
+revision = "0040_migrate_assets_paths"
+down_revision = "0039_add_retro_achievements"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    conn = op.get_bind()
+
+    # Table configuration: whether each table has an emulator column
+    tables_with_emulator_column = {
+        "saves": True,
+        "states": True,
+        "screenshots": False,
+    }
+
+    for table, has_emulator in tables_with_emulator_column.items():
+        log.info(f"Processing table: {table}")
+
+        # Database-specific LIKE clause
+        like_clause = (
+            "file_path NOT LIKE '%%/' || rom_id::text || '/%%'"
+            if is_postgresql(conn)
+            else "file_path NOT LIKE CONCAT('%/', rom_id, '/%')"
+        )
+
+        # Build query based on whether emulator exists
+        select_cols = "id, file_path, rom_id" + (", emulator" if has_emulator else "")
+        results = conn.execute(
+            text(
+                f"""
+                SELECT {select_cols}
+                FROM {table}
+                WHERE {like_clause}
+            """  # nosec B608
+            )
+        ).fetchall()
+
+        for row in results:
+            item_id = row.id
+            old_path = row.file_path
+            rom_id = row.rom_id
+            emulator = row.emulator if has_emulator else None
+
+            if not old_path or not rom_id:
+                continue  # Skip incomplete records
+
+            parts = old_path.split("/")
+            try:
+                user_id = parts[1]
+                platform = parts[3]
+            except IndexError:
+                log.info(f"[{table}] Skipping malformed path: {old_path}")
+                continue
+
+            # Always include rom_id in the path
+            path_parts = ["users", user_id, table, platform, str(rom_id)]
+            if emulator:
+                path_parts.append(emulator)
+            new_path = "/".join(path_parts)
+
+            old_abs = os.path.join(ASSETS_BASE_PATH, *old_path.split("/"))
+            new_abs = os.path.join(ASSETS_BASE_PATH, *new_path.split("/"))
+
+            try:
+                os.makedirs(os.path.dirname(new_abs), exist_ok=True)
+
+                if os.path.exists(old_abs):
+                    if table == "screenshots" and os.path.isdir(old_abs):
+                        # Move files individually to avoid moving directory into itself
+                        for filename in os.listdir(old_abs):
+                            src = os.path.join(old_abs, filename)
+                            dst = os.path.join(new_abs, filename)
+                            os.makedirs(os.path.dirname(dst), exist_ok=True)
+                            os.rename(src, dst)
+                            log.info(f"[{table}] Moved file: {src} -> {dst}")
+                    else:
+                        os.rename(old_abs, new_abs)
+                        log.info(f"[{table}] Moved: {old_abs} -> {new_abs}")
+                else:
+                    log.info(f"[{table}] Old path does not exist: {old_abs}")
+            except Exception as e:
+                log.error(f"[{table}] Error moving {old_abs} to {new_abs}: {e}")
+                continue
+
+            # Update DB
+            conn.execute(
+                text(
+                    f"""
+                    UPDATE {table}
+                    SET file_path = :new_path
+                    WHERE id = :item_id
+                """  # nosec B608
+                ),
+                {"new_path": new_path, "item_id": item_id},
+            )
+
+
+def downgrade() -> None:
+    # Optional: implement reverse logic if desired
+    pass
diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py
index 992a6950cf..cd2f53d141 100644
--- a/backend/endpoints/rom.py
+++ b/backend/endpoints/rom.py
@@ -794,9 +794,7 @@ async def delete_roms(
             try:
                 fs_rom_handler.remove_from_fs(fs_path=rom.fs_path, fs_name=rom.fs_name)
             except FileNotFoundError as exc:
-                error = (
-                    f"Rom file {rom.fs_name} not found for platform {rom.platform_slug}"
-                )
+                error = f"Rom file {hl(rom.fs_name)} not found for platform {hl(rom.platform_display_name, color=BLUE)}[{hl(rom.platform_slug)}]"
                 log.error(error)
                 raise HTTPException(
                     status_code=status.HTTP_404_NOT_FOUND, detail=error
diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py
index f9e24256ab..937f91c17b 100644
--- a/backend/endpoints/saves.py
+++ b/backend/endpoints/saves.py
@@ -27,6 +27,19 @@ async def add_save(
 ) -> SaveSchema:
     data = await request.form()
 
+    rom = db_rom_handler.get_rom(rom_id)
+    if not rom:
+        raise RomNotFoundInDatabaseException(rom_id)
+
+    log.info(f"Uploading save of {rom.name}")
+
+    saves_path = fs_asset_handler.build_saves_file_path(
+        user=request.user,
+        platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom_id,
+        emulator=emulator,
+    )
+
     if "saveFile" not in data:
         log.error("No save file provided")
         raise HTTPException(
@@ -48,7 +61,10 @@ async def add_save(
     log.info(f"Uploading save {hl(saveFile.filename)} for {hl(rom.name, color=BLUE)}")
 
     saves_path = fs_asset_handler.build_saves_file_path(
-        user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator
+        user=request.user,
+        platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom.id,
+        emulator=emulator,
     )
 
     fs_asset_handler.write_file(file=saveFile, path=saves_path)
@@ -58,6 +74,7 @@ async def add_save(
         file_name=saveFile.filename,
         user=request.user,
         platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom_id,
         emulator=emulator,
     )
     db_save = db_save_handler.get_save_by_filename(
@@ -76,7 +93,7 @@ async def add_save(
     screenshotFile: UploadFile | None = data.get("screenshotFile", None)  # type: ignore
     if screenshotFile and screenshotFile.filename:
         screenshots_path = fs_asset_handler.build_screenshots_file_path(
-            user=request.user, platform_fs_slug=rom.platform_slug
+            user=request.user, platform_fs_slug=rom.platform_slug, rom_id=rom.id
         )
 
         fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
@@ -86,6 +103,7 @@ async def add_save(
             file_name=screenshotFile.filename,
             user=request.user,
             platform_fs_slug=rom.platform_slug,
+            rom_id=rom.id,
         )
         db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
             rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename
@@ -159,7 +177,9 @@ async def update_save(request: Request, id: int) -> SaveSchema:
     screenshotFile: UploadFile | None = data.get("screenshotFile", None)  # type: ignore
     if screenshotFile and screenshotFile.filename:
         screenshots_path = fs_asset_handler.build_screenshots_file_path(
-            user=request.user, platform_fs_slug=db_save.rom.platform_slug
+            user=request.user,
+            platform_fs_slug=db_save.rom.platform_slug,
+            rom_id=db_save.rom.id,
         )
 
         fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
@@ -169,6 +189,7 @@ async def update_save(request: Request, id: int) -> SaveSchema:
             file_name=screenshotFile.filename,
             user=request.user,
             platform_fs_slug=db_save.rom.platform_slug,
+            rom_id=db_save.rom.id,
         )
         db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
             rom_id=db_save.rom.id,
@@ -224,7 +245,7 @@ async def delete_saves(request: Request) -> list[int]:
                 file_name=save.file_name, file_path=save.file_path
             )
         except FileNotFoundError:
-            error = f"Save file {save.file_name} not found for platform {save.rom.platform_slug}"
+            error = f"Save file {hl(save.file_name)} not found for platform {hl(save.rom.platform_display_name, color=BLUE)}[{hl(save.rom.platform_slug)}]"
             log.error(error)
 
         if save.screenshot:
@@ -236,7 +257,7 @@ async def delete_saves(request: Request) -> list[int]:
                     file_path=save.screenshot.file_path,
                 )
             except FileNotFoundError:
-                error = f"Screenshot file {save.screenshot.file_name} not found for save {save.file_name}"
+                error = f"Screenshot file {hl(save.screenshot.file_name)} not found for save {hl(save.file_name)}[{hl(save.rom.platform_slug)}]"
                 log.error(error)
 
     return save_ids
diff --git a/backend/endpoints/screenshots.py b/backend/endpoints/screenshots.py
index d6d01f3679..c5a4f50c8c 100644
--- a/backend/endpoints/screenshots.py
+++ b/backend/endpoints/screenshots.py
@@ -32,7 +32,7 @@ async def add_screenshot(
     log.info(f"Uploading screenshots to {hl(rom.name, color=BLUE)}")
 
     screenshots_path = fs_asset_handler.build_screenshots_file_path(
-        user=request.user, platform_fs_slug=rom.platform_slug
+        user=request.user, platform_fs_slug=rom.platform_slug, rom_id=rom.id
     )
 
     if "screenshotFile" not in data:
@@ -63,6 +63,7 @@ async def add_screenshot(
         file_name=screenshotFile.filename,
         user=request.user,
         platform_fs_slug=rom.platform_slug,
+        rom_id=rom.id,
     )
     db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
         rom_id=rom.id, user_id=current_user.id, file_name=screenshotFile.filename
diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py
index f0d373fa61..9c04f3164b 100644
--- a/backend/endpoints/states.py
+++ b/backend/endpoints/states.py
@@ -27,6 +27,19 @@ async def add_state(
 ) -> StateSchema:
     data = await request.form()
 
+    rom = db_rom_handler.get_rom(rom_id)
+    if not rom:
+        raise RomNotFoundInDatabaseException(rom_id)
+
+    log.info(f"Uploading state of {rom.name}")
+
+    states_path = fs_asset_handler.build_states_file_path(
+        user=request.user,
+        platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom_id,
+        emulator=emulator,
+    )
+
     if "stateFile" not in data:
         log.error("No state file provided")
         raise HTTPException(
@@ -48,7 +61,10 @@ async def add_state(
     log.info(f"Uploading state {hl(stateFile.filename)} for {hl(rom.name, color=BLUE)}")
 
     states_path = fs_asset_handler.build_states_file_path(
-        user=request.user, platform_fs_slug=rom.platform.fs_slug, emulator=emulator
+        user=request.user,
+        platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom.id,
+        emulator=emulator,
     )
 
     fs_asset_handler.write_file(file=stateFile, path=states_path)
@@ -58,6 +74,7 @@ async def add_state(
         file_name=stateFile.filename,
         user=request.user,
         platform_fs_slug=rom.platform.fs_slug,
+        rom_id=rom_id,
         emulator=emulator,
     )
     db_state = db_state_handler.get_state_by_filename(
@@ -76,7 +93,7 @@ async def add_state(
     screenshotFile: UploadFile | None = data.get("screenshotFile", None)  # type: ignore
     if screenshotFile and screenshotFile.filename:
         screenshots_path = fs_asset_handler.build_screenshots_file_path(
-            user=request.user, platform_fs_slug=rom.platform_slug
+            user=request.user, platform_fs_slug=rom.platform_slug, rom_id=rom.id
         )
 
         fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
@@ -86,6 +103,7 @@ async def add_state(
             file_name=screenshotFile.filename,
             user=request.user,
             platform_fs_slug=rom.platform_slug,
+            rom_id=rom.id,
         )
         db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
             rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename
@@ -161,7 +179,9 @@ async def update_state(request: Request, id: int) -> StateSchema:
     screenshotFile: UploadFile | None = data.get("screenshotFile", None)  # type: ignore
     if screenshotFile and screenshotFile.filename:
         screenshots_path = fs_asset_handler.build_screenshots_file_path(
-            user=request.user, platform_fs_slug=db_state.rom.platform_slug
+            user=request.user,
+            platform_fs_slug=db_state.rom.platform_slug,
+            rom_id=db_state.rom.id,
         )
 
         fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
@@ -171,6 +191,7 @@ async def update_state(request: Request, id: int) -> StateSchema:
             file_name=screenshotFile.filename,
             user=request.user,
             platform_fs_slug=db_state.rom.platform_slug,
+            rom_id=db_state.rom.id,
         )
         db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
             rom_id=db_state.rom.id,
@@ -228,7 +249,7 @@ async def delete_states(request: Request) -> list[int]:
                 file_name=state.file_name, file_path=state.file_path
             )
         except FileNotFoundError:
-            error = f"State file {state.file_name} not found for platform {state.rom.platform_slug}"
+            error = f"State file {hl(state.file_name)} not found for platform {hl(state.rom.platform_display_name, color=BLUE)}[{hl(state.rom.platform_slug)}]"
             log.error(error)
 
         if state.screenshot:
@@ -240,7 +261,7 @@ async def delete_states(request: Request) -> list[int]:
                     file_path=state.screenshot.file_path,
                 )
             except FileNotFoundError:
-                error = f"Screenshot file {state.screenshot.file_name} not found for state {state.file_name}"
+                error = f"Screenshot file {hl(state.screenshot.file_name)} not found for state {hl(state.file_name)}[{hl(state.rom.platform_slug)}]"
                 log.error(error)
 
     return state_ids
diff --git a/backend/exceptions/fs_exceptions.py b/backend/exceptions/fs_exceptions.py
index 942c596d2a..00b90b0b84 100644
--- a/backend/exceptions/fs_exceptions.py
+++ b/backend/exceptions/fs_exceptions.py
@@ -1,3 +1,6 @@
+from logger.formatter import BLUE
+from logger.formatter import highlight as hl
+
 folder_struct_msg = "Check RomM folder structure here: https://github.com/rommapp/romm?tab=readme-ov-file#folder-structure"
 
 
@@ -30,7 +33,7 @@ def __repr__(self):
 
 class RomsNotFoundException(Exception):
     def __init__(self, platform: str):
-        self.message = f"Roms not found for platform {platform}. {folder_struct_msg}"
+        self.message = f"Roms not found for platform {hl(platform, color=BLUE)}. {folder_struct_msg}"
         super().__init__(self.message)
 
     def __repr__(self):
@@ -39,7 +42,7 @@ def __repr__(self):
 
 class RomAlreadyExistsException(Exception):
     def __init__(self, rom_name: str):
-        self.message = f"Can't rename: {rom_name} already exists"
+        self.message = f"Can't rename: {hl(rom_name)} already exists"
         super().__init__(self.message)
 
     def __repr__(self):
@@ -48,9 +51,7 @@ def __repr__(self):
 
 class FirmwareNotFoundException(Exception):
     def __init__(self, platform: str):
-        self.message = (
-            f"Firmware not found for platform {platform}. {folder_struct_msg}"
-        )
+        self.message = f"Firmware not found for platform {hl(platform, color=BLUE)}. {folder_struct_msg}"
         super().__init__(self.message)
 
     def __repr__(self):
@@ -59,7 +60,7 @@ def __repr__(self):
 
 class FirmwareAlreadyExistsException(Exception):
     def __init__(self, firmware_name: str):
-        self.message = f"Can't rename: {firmware_name} already exists"
+        self.message = f"Can't rename: {hl(firmware_name)} already exists"
         super().__init__(self.message)
 
     def __repr__(self):
diff --git a/backend/handler/filesystem/assets_handler.py b/backend/handler/filesystem/assets_handler.py
index e8a3299455..30cdbbba2f 100644
--- a/backend/handler/filesystem/assets_handler.py
+++ b/backend/handler/filesystem/assets_handler.py
@@ -42,25 +42,49 @@ def build_avatar_path(self, user: User):
         return os.path.join(self.user_folder_path(user), "profile")
 
     def _build_asset_file_path(
-        self, user: User, folder: str, platform_fs_slug, emulator: str | None = None
+        self,
+        user: User,
+        folder: str,
+        platform_fs_slug: str,
+        rom_id: int,
+        emulator: str | None = None,
     ):
         user_folder_path = self.user_folder_path(user)
+        assets_path = os.path.join(
+            user_folder_path, folder, platform_fs_slug, str(rom_id)
+        )
         if emulator:
-            return os.path.join(user_folder_path, folder, platform_fs_slug, emulator)
-        return os.path.join(user_folder_path, folder, platform_fs_slug)
+            assets_path = os.path.join(assets_path, emulator)
+        return assets_path
 
-    # /users/557365723a31/saves/n64/mupen64plus
+    # /users/557365723a31/saves/n64/{rom.id}/mupen64plus/
     def build_saves_file_path(
-        self, user: User, platform_fs_slug: str, emulator: str | None = None
+        self,
+        user: User,
+        platform_fs_slug: str,
+        rom_id: int,
+        emulator: str | None = None,
     ):
-        return self._build_asset_file_path(user, "saves", platform_fs_slug, emulator)
+        return self._build_asset_file_path(
+            user, "saves", platform_fs_slug, rom_id, emulator
+        )
 
-    # /users/557365723a31/states/n64/mupen64plus
+    # /users/557365723a31/states/n64/{rom.id}/mupen64plus
     def build_states_file_path(
-        self, user: User, platform_fs_slug: str, emulator: str | None = None
+        self,
+        user: User,
+        platform_fs_slug: str,
+        rom_id: int,
+        emulator: str | None = None,
     ):
-        return self._build_asset_file_path(user, "states", platform_fs_slug, emulator)
+        return self._build_asset_file_path(
+            user, "states", platform_fs_slug, rom_id, emulator
+        )
 
-    # /users/557365723a31/screenshots/n64
-    def build_screenshots_file_path(self, user: User, platform_fs_slug: str):
-        return self._build_asset_file_path(user, "screenshots", platform_fs_slug)
+    # /users/557365723a31/screenshots/{rom.id}/n64
+    def build_screenshots_file_path(
+        self, user: User, platform_fs_slug: str, rom_id: int
+    ):
+        return self._build_asset_file_path(
+            user, "screenshots", platform_fs_slug, rom_id
+        )
diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py
index bc60df3fbb..6dac461707 100644
--- a/backend/handler/scan_handler.py
+++ b/backend/handler/scan_handler.py
@@ -414,19 +414,27 @@ def _scan_asset(file_name: str, path: str):
 
 
 def scan_save(
-    file_name: str, user: User, platform_fs_slug: str, emulator: str | None = None
+    file_name: str,
+    user: User,
+    platform_fs_slug: str,
+    rom_id: int,
+    emulator: str | None = None,
 ) -> Save:
     saves_path = fs_asset_handler.build_saves_file_path(
-        user=user, platform_fs_slug=platform_fs_slug, emulator=emulator
+        user=user, platform_fs_slug=platform_fs_slug, rom_id=rom_id, emulator=emulator
     )
     return Save(**_scan_asset(file_name, saves_path))
 
 
 def scan_state(
-    file_name: str, user: User, platform_fs_slug: str, emulator: str | None = None
+    file_name: str,
+    user: User,
+    platform_fs_slug: str,
+    rom_id: int,
+    emulator: str | None = None,
 ) -> State:
     states_path = fs_asset_handler.build_states_file_path(
-        user=user, platform_fs_slug=platform_fs_slug, emulator=emulator
+        user=user, platform_fs_slug=platform_fs_slug, rom_id=rom_id, emulator=emulator
     )
     return State(**_scan_asset(file_name, states_path))
 
@@ -435,8 +443,9 @@ def scan_screenshot(
     file_name: str,
     user: User,
     platform_fs_slug: str,
+    rom_id: int,
 ) -> Screenshot:
     screenshots_path = fs_asset_handler.build_screenshots_file_path(
-        user=user, platform_fs_slug=platform_fs_slug
+        user=user, platform_fs_slug=platform_fs_slug, rom_id=rom_id
     )
     return Screenshot(**_scan_asset(file_name, screenshots_path))
diff --git a/frontend/src/components/Details/Saves.vue b/frontend/src/components/Details/Saves.vue
index 21765d6d64..fff790fd63 100644
--- a/frontend/src/components/Details/Saves.vue
+++ b/frontend/src/components/Details/Saves.vue
@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import EmptySaves from "@/components/common/EmptyStates/EmptySaves.vue";
 import type { SaveSchema } from "@/__generated__";
 import storeAuth from "@/stores/auth";
 import { type DetailedRom } from "@/stores/roms";
@@ -67,9 +68,9 @@ function onCardClick(save: SaveSchema, event: MouseEvent) {
 </script>
 
 <template>
-  <v-row class="mt-2" no-gutters>
-    <v-col>
-      <v-btn-group divided density="default" class="mb-1 mx-1">
+  <v-row class="ma-2" no-gutters>
+    <v-col class="pa-1">
+      <v-btn-group divided density="default">
         <v-btn
           v-if="scopes.includes('assets.write')"
           drawer
@@ -108,12 +109,12 @@ function onCardClick(save: SaveSchema, event: MouseEvent) {
       </v-btn-group>
     </v-col>
   </v-row>
-  <v-row v-if="rom.user_saves.length > 0" no-gutters>
-    <v-col cols="6" sm="4" v-for="save in rom.user_saves">
+  <v-row v-if="rom.user_saves.length > 0" class="ma-2" no-gutters>
+    <v-col cols="6" sm="4" class="pa-1" v-for="save in rom.user_saves">
       <v-hover v-slot="{ isHovering, props }">
         <v-card
           v-bind="props"
-          class="bg-toplayer transform-scale ma-1"
+          class="bg-toplayer transform-scale"
           :class="{
             'on-hover': isHovering,
             'border-selected': selectedSaves.some((s) => s.id === save.id),
@@ -121,75 +122,73 @@ function onCardClick(save: SaveSchema, event: MouseEvent) {
           :elevation="isHovering ? 20 : 3"
           @click="(e) => onCardClick(save, e)"
         >
-          <v-card-text
-            class="d-flex flex-column justify-end h-100"
-            style="padding: 1.5rem"
-          >
-            <v-row class="position-relative">
-              <v-img
-                cover
-                height="100%"
-                min-height="75px"
-                :src="
-                  save.screenshot?.download_path ??
-                  getEmptyCoverImage(save.file_name)
-                "
-              />
-              <v-slide-x-transition>
-                <v-btn-group
-                  v-if="isHovering"
-                  class="position-absolute"
-                  density="compact"
-                  style="bottom: 4px; right: 4px"
+          <v-card-text class="pa-2">
+            <v-row no-gutters>
+              <v-col cols="12">
+                <v-img
+                  rounded
+                  :src="
+                    save.screenshot?.download_path ??
+                    getEmptyCoverImage(save.file_name)
+                  "
                 >
-                  <v-btn
-                    drawer
-                    :href="save.download_path"
-                    download
-                    size="small"
-                  >
-                    <v-icon>mdi-download</v-icon>
-                  </v-btn>
-                  <v-btn
-                    v-if="scopes.includes('assets.write')"
-                    drawer
-                    size="small"
-                    @click="
-                      emitter?.emit('showDeleteSavesDialog', {
-                        rom: props.rom,
-                        saves: [save],
-                      })
-                    "
-                  >
-                    <v-icon class="text-romm-red">mdi-delete</v-icon>
-                  </v-btn>
-                </v-btn-group>
-              </v-slide-x-transition>
+                  <v-slide-x-transition>
+                    <v-btn-group
+                      v-if="isHovering"
+                      class="position-absolute"
+                      density="compact"
+                      style="bottom: 4px; right: 4px"
+                    >
+                      <v-btn
+                        drawer
+                        :href="save.download_path"
+                        download
+                        size="small"
+                      >
+                        <v-icon>mdi-download</v-icon>
+                      </v-btn>
+                      <v-btn
+                        v-if="scopes.includes('assets.write')"
+                        drawer
+                        size="small"
+                        @click="
+                          emitter?.emit('showDeleteSavesDialog', {
+                            rom: props.rom,
+                            saves: [save],
+                          })
+                        "
+                      >
+                        <v-icon class="text-romm-red">mdi-delete</v-icon>
+                      </v-btn>
+                    </v-btn-group>
+                  </v-slide-x-transition>
+                </v-img>
+              </v-col>
             </v-row>
-            <v-row class="mt-6 flex-grow-0">{{ save.file_name }}</v-row>
-            <v-row
-              class="mt-6 d-flex flex-md-wrap ga-2 flex-grow-0"
-              style="min-height: 20px"
-            >
-              <v-chip v-if="save.emulator" size="x-small" color="orange" label>
-                {{ save.emulator }}
-              </v-chip>
-              <v-chip size="x-small" label>
-                {{ formatBytes(save.file_size_bytes) }}
-              </v-chip>
-              <v-chip size="x-small" label>
-                Updated: {{ formatTimestamp(save.updated_at) }}
-              </v-chip>
+            <v-row class="py-2 text-caption" no-gutters>{{
+              save.file_name
+            }}</v-row>
+            <v-row class="ga-1" no-gutters>
+              <v-col v-if="save.emulator" cols="12">
+                <v-chip size="x-small" color="orange" label>
+                  {{ save.emulator }}
+                </v-chip>
+              </v-col>
+              <v-col cols="12">
+                <v-chip size="x-small" label>
+                  {{ formatBytes(save.file_size_bytes) }}
+                </v-chip>
+              </v-col>
+              <v-col cols="12">
+                <v-chip size="x-small" label>
+                  Updated: {{ formatTimestamp(save.updated_at) }}
+                </v-chip>
+              </v-col>
             </v-row>
           </v-card-text>
         </v-card>
       </v-hover>
     </v-col>
   </v-row>
-  <v-row v-else class="text-center">
-    <v-col>
-      <v-icon size="large">mdi-help-rhombus-outline</v-icon>
-      <p class="text-h6 mt-2">{{ t("rom.no-saves-found") }}</p>
-    </v-col>
-  </v-row>
+  <empty-saves v-else />
 </template>
diff --git a/frontend/src/components/Details/States.vue b/frontend/src/components/Details/States.vue
index 35301fcd3a..dd5c993135 100644
--- a/frontend/src/components/Details/States.vue
+++ b/frontend/src/components/Details/States.vue
@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import EmptySates from "@/components/common/EmptyStates/EmptyStates.vue";
 import type { StateSchema } from "@/__generated__";
 import { type DetailedRom } from "@/stores/roms";
 import type { Events } from "@/types/emitter";
@@ -69,9 +70,9 @@ function onCardClick(state: StateSchema, event: MouseEvent) {
 </script>
 
 <template>
-  <v-row class="mt-2" no-gutters>
-    <v-col>
-      <v-btn-group divided density="default" class="mb-1 mx-1">
+  <v-row class="ma-2" no-gutters>
+    <v-col class="pa-1">
+      <v-btn-group divided density="default">
         <v-btn
           v-if="scopes.includes('assets.write')"
           drawer
@@ -110,12 +111,12 @@ function onCardClick(state: StateSchema, event: MouseEvent) {
       </v-btn-group>
     </v-col>
   </v-row>
-  <v-row v-if="rom.user_states.length > 0" no-gutters>
-    <v-col cols="6" sm="4" v-for="state in rom.user_states">
+  <v-row v-if="rom.user_states.length > 0" class="ma-2" no-gutters>
+    <v-col cols="6" sm="4" class="pa-1" v-for="state in rom.user_states">
       <v-hover v-slot="{ isHovering, props }">
         <v-card
           v-bind="props"
-          class="bg-toplayer transform-scale ma-1"
+          class="bg-toplayer transform-scale"
           :class="{
             'on-hover': isHovering,
             'border-selected': selectedStates.some((s) => s.id === state.id),
@@ -123,75 +124,73 @@ function onCardClick(state: StateSchema, event: MouseEvent) {
           :elevation="isHovering ? 20 : 3"
           @click="(e) => onCardClick(state, e)"
         >
-          <v-card-text
-            class="d-flex flex-column justify-end h-100"
-            style="padding: 1.5rem"
-          >
-            <v-row class="position-relative">
-              <v-img
-                cover
-                height="100%"
-                min-height="75px"
-                :src="
-                  state.screenshot?.download_path ??
-                  getEmptyCoverImage(state.file_name)
-                "
-              />
-              <v-slide-x-transition>
-                <v-btn-group
-                  v-if="isHovering"
-                  class="position-absolute"
-                  density="compact"
-                  style="bottom: 4px; right: 4px"
+          <v-card-text class="pa-2">
+            <v-row no-gutters>
+              <v-col cols="12">
+                <v-img
+                  rounded
+                  :src="
+                    state.screenshot?.download_path ??
+                    getEmptyCoverImage(state.file_name)
+                  "
                 >
-                  <v-btn
-                    drawer
-                    :href="state.download_path"
-                    download
-                    size="small"
-                  >
-                    <v-icon>mdi-download</v-icon>
-                  </v-btn>
-                  <v-btn
-                    v-if="scopes.includes('assets.write')"
-                    drawer
-                    size="small"
-                    @click="
-                      emitter?.emit('showDeleteStatesDialog', {
-                        rom: props.rom,
-                        states: [state],
-                      })
-                    "
-                  >
-                    <v-icon class="text-romm-red">mdi-delete</v-icon>
-                  </v-btn>
-                </v-btn-group>
-              </v-slide-x-transition>
+                  <v-slide-x-transition>
+                    <v-btn-group
+                      v-if="isHovering"
+                      class="position-absolute"
+                      density="compact"
+                      style="bottom: 4px; right: 4px"
+                    >
+                      <v-btn
+                        drawer
+                        :href="state.download_path"
+                        download
+                        size="small"
+                      >
+                        <v-icon>mdi-download</v-icon>
+                      </v-btn>
+                      <v-btn
+                        v-if="scopes.includes('assets.write')"
+                        drawer
+                        size="small"
+                        @click="
+                          emitter?.emit('showDeleteStatesDialog', {
+                            rom: props.rom,
+                            states: [state],
+                          })
+                        "
+                      >
+                        <v-icon class="text-romm-red">mdi-delete</v-icon>
+                      </v-btn>
+                    </v-btn-group>
+                  </v-slide-x-transition>
+                </v-img>
+              </v-col>
             </v-row>
-            <v-row class="mt-6 flex-grow-0">{{ state.file_name }}</v-row>
-            <v-row
-              class="mt-6 d-flex flex-md-wrap ga-2 flex-grow-0"
-              style="min-height: 20px"
-            >
-              <v-chip v-if="state.emulator" size="x-small" color="orange" label>
-                {{ state.emulator }}
-              </v-chip>
-              <v-chip size="x-small" label>
-                {{ formatBytes(state.file_size_bytes) }}
-              </v-chip>
-              <v-chip size="x-small" label>
-                Updated: {{ formatTimestamp(state.updated_at) }}
-              </v-chip>
+            <v-row class="py-2 text-caption" no-gutters>{{
+              state.file_name
+            }}</v-row>
+            <v-row class="ga-1" no-gutters>
+              <v-col v-if="state.emulator" cols="12">
+                <v-chip size="x-small" color="orange" label>
+                  {{ state.emulator }}
+                </v-chip>
+              </v-col>
+              <v-col cols="12">
+                <v-chip size="x-small" label>
+                  {{ formatBytes(state.file_size_bytes) }}
+                </v-chip>
+              </v-col>
+              <v-col cols="12">
+                <v-chip size="x-small" label>
+                  Updated: {{ formatTimestamp(state.updated_at) }}
+                </v-chip>
+              </v-col>
             </v-row>
           </v-card-text>
         </v-card>
       </v-hover>
     </v-col>
   </v-row>
-  <v-row v-else class="text-center">
-    <v-col>
-      <v-icon size="large">mdi-help-rhombus-outline</v-icon>
-      <p class="text-h6 mt-2">{{ t("rom.no-states-found") }}</p>
-    </v-col>
-  </v-row>
+  <empty-sates v-else />
 </template>
diff --git a/frontend/src/components/common/EmptyStates/EmptySaves.vue b/frontend/src/components/common/EmptyStates/EmptySaves.vue
new file mode 100644
index 0000000000..d5bdbe6fde
--- /dev/null
+++ b/frontend/src/components/common/EmptyStates/EmptySaves.vue
@@ -0,0 +1,14 @@
+<script setup lang="ts">
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n();
+</script>
+
+<template>
+  <v-row class="text-center">
+    <v-col>
+      <v-icon size="large">mdi-help-rhombus-outline</v-icon>
+      <p class="text-h6 mt-2">{{ t("rom.no-saves-found") }}</p>
+    </v-col>
+  </v-row>
+</template>
diff --git a/frontend/src/components/common/EmptyStates/EmptyStates.vue b/frontend/src/components/common/EmptyStates/EmptyStates.vue
new file mode 100644
index 0000000000..7e9d7834c0
--- /dev/null
+++ b/frontend/src/components/common/EmptyStates/EmptyStates.vue
@@ -0,0 +1,14 @@
+<script setup lang="ts">
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n();
+</script>
+
+<template>
+  <v-row class="text-center">
+    <v-col>
+      <v-icon size="large">mdi-help-rhombus-outline</v-icon>
+      <p class="text-h6 mt-2">{{ t("rom.no-states-found") }}</p>
+    </v-col>
+  </v-row>
+</template>
diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json
index f6f7489a1b..a0ef709436 100644
--- a/frontend/src/locales/de_DE/play.json
+++ b/frontend/src/locales/de_DE/play.json
@@ -10,5 +10,9 @@
   "quit": "Beenden",
   "save-and-quit": "Speichern und beenden",
   "select-save": "Speicherstand auswählen",
-  "select-state": "Speicherstand auswählen"
+  "change-save": "Speicherstand ändern",
+  "deselect-save": "Speicherstand abwählen",
+  "select-state": "Speicherstand auswählen",
+  "change-state": "Speicherstand ändern",
+  "deselect-state": "Speicherstand abwählen"
 }
diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json
index aa9496c466..d7420ed806 100644
--- a/frontend/src/locales/en_GB/play.json
+++ b/frontend/src/locales/en_GB/play.json
@@ -10,5 +10,9 @@
   "quit": "Quit",
   "save-and-quit": "Save and quit",
   "select-save": "Select save",
-  "select-state": "Select state"
+  "change-save": "Change save",
+  "deselect-save": "Deselect save",
+  "select-state": "Select state",
+  "change-state": "Change state",
+  "deselect-state": "Deselect state"
 }
diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json
index aa9496c466..d7420ed806 100644
--- a/frontend/src/locales/en_US/play.json
+++ b/frontend/src/locales/en_US/play.json
@@ -10,5 +10,9 @@
   "quit": "Quit",
   "save-and-quit": "Save and quit",
   "select-save": "Select save",
-  "select-state": "Select state"
+  "change-save": "Change save",
+  "deselect-save": "Deselect save",
+  "select-state": "Select state",
+  "change-state": "Change state",
+  "deselect-state": "Deselect state"
 }
diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json
index f22bcea047..f99d09c039 100644
--- a/frontend/src/locales/es_ES/play.json
+++ b/frontend/src/locales/es_ES/play.json
@@ -10,5 +10,9 @@
   "quit": "Salir",
   "save-and-quit": "Guardar y salir",
   "select-save": "Seleccionar guardado",
-  "select-state": "Seleccionar estado"
+  "change-save": "Cambiar guardado",
+  "deselect-save": "Deseleccionar guardado",
+  "select-state": "Seleccionar estado",
+  "change-state": "Cambiar estado",
+  "deselect-state": "Deseleccionar estado"
 }
diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json
index f7670dd84c..dc3fbe7189 100644
--- a/frontend/src/locales/fr_FR/play.json
+++ b/frontend/src/locales/fr_FR/play.json
@@ -10,5 +10,9 @@
   "quit": "Quitter",
   "save-and-quit": "Sauvegarder et quitter",
   "select-save": "Sélectionner la sauvegarde",
-  "select-state": "Sélectionner l'état"
+  "change-save": "Changer la sauvegarde",
+  "deselect-save": "Désélectionner la sauvegarde",
+  "select-state": "Sélectionner l'état",
+  "change-state": "Changer l'état",
+  "deselect-state": "Désélectionner l'état"
 }
diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json
index 817d595115..904b1878f6 100644
--- a/frontend/src/locales/it_IT/play.json
+++ b/frontend/src/locales/it_IT/play.json
@@ -11,5 +11,9 @@
   "reset-session": "Reset Sessione",
   "save-and-quit": "Salva e esci",
   "select-save": "Seleziona Salvataggio",
-  "select-state": "Seleziona Stato"
+  "change-save": "Cambia Salvataggio",
+  "deselect-save": "Deseleziona Salvataggio",
+  "select-state": "Seleziona Stato",
+  "change-state": "Cambia Stato",
+  "deselect-state": "Deseleziona Stato"
 }
diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json
index 8b73f4ba73..8fd2005fff 100644
--- a/frontend/src/locales/ja_JP/play.json
+++ b/frontend/src/locales/ja_JP/play.json
@@ -10,5 +10,9 @@
   "quit": "終了",
   "save-and-quit": "保存して終了",
   "select-save": "セーブデータを選択",
-  "select-state": "ステートを選択"
+  "change-save": "セーブデータを変更",
+  "deselect-save": "セーブデータを解除",
+  "select-state": "ステートを選択",
+  "change-state": "ステートを変更",
+  "deselect-state": "ステートを解除"
 }
diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json
index 288228919e..49c2c32292 100644
--- a/frontend/src/locales/ko_KR/play.json
+++ b/frontend/src/locales/ko_KR/play.json
@@ -10,5 +10,9 @@
   "quit": "종료",
   "save-and-quit": "저장하고 종료",
   "select-save": "세이브 선택",
-  "select-state": "상태 선택"
+  "change-save": "세이브 변경",
+  "deselect-save": "세이브 선택 해제",
+  "select-state": "상태 선택",
+  "change-state": "상태 변경",
+  "deselect-state": "상태 선택 해제"
 }
diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json
index 9d05b259fe..deefd1ea90 100644
--- a/frontend/src/locales/pt_BR/play.json
+++ b/frontend/src/locales/pt_BR/play.json
@@ -10,5 +10,9 @@
   "quit": "Sair",
   "save-and-quit": "Salvar e sair",
   "select-save": "Selecionar save",
-  "select-state": "Selecionar estado"
+  "change-save": "Alterar save",
+  "deselect-save": "Desmarcar save",
+  "select-state": "Selecionar estado",
+  "change-state": "Alterar estado",
+  "deselect-state": "Desmarcar estado"
 }
diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json
index 43ae52963c..62de5935f1 100644
--- a/frontend/src/locales/ro_RO/play.json
+++ b/frontend/src/locales/ro_RO/play.json
@@ -10,5 +10,9 @@
   "quit": "Ieși",
   "save-and-quit": "Salvează și ieși",
   "select-save": "Selectează salvare",
-  "select-state": "Selectează stare"
+  "change-save": "Schimbă salvare",
+  "deselect-save": "Deselectează salvare",
+  "select-state": "Selectează stare",
+  "change-state": "Schimbă stare",
+  "deselect-state": "Deselectează stare"
 }
diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json
index 680226edc4..1d36545311 100644
--- a/frontend/src/locales/ru_RU/play.json
+++ b/frontend/src/locales/ru_RU/play.json
@@ -10,5 +10,9 @@
   "quit": "Выйти",
   "save-and-quit": "Сохранить и выйти",
   "select-save": "Выбрать сохранение",
-  "select-state": "Выбрать состояние"
+  "change-save": "Изменить сохранение",
+  "deselect-save": "Снять выбор сохранения",
+  "select-state": "Выбрать состояние",
+  "change-state": "Изменить состояние",
+  "deselect-state": "Снять выбор состояния"
 }
diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json
index 5257f48a49..18644b0819 100644
--- a/frontend/src/locales/zh_CN/play.json
+++ b/frontend/src/locales/zh_CN/play.json
@@ -10,5 +10,9 @@
   "quit": "退出",
   "save-and-quit": "保存并退出",
   "select-save": "选择存档",
-  "select-state": "选择状态"
+  "change-save": "更改存档",
+  "deselect-save": "取消选择存档",
+  "select-state": "选择状态",
+  "change-state": "更改状态",
+  "deselect-state": "取消选择状态"
 }
diff --git a/frontend/src/views/Player/EmulatorJS/Base.vue b/frontend/src/views/Player/EmulatorJS/Base.vue
index bdb084712f..3693a1dcb4 100644
--- a/frontend/src/views/Player/EmulatorJS/Base.vue
+++ b/frontend/src/views/Player/EmulatorJS/Base.vue
@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import EmptySaves from "@/components/common/EmptyStates/EmptySaves.vue";
+import EmptyStates from "@/components/common/EmptyStates/EmptyStates.vue";
 import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
 import RomListItem from "@/components/common/Game/ListItem.vue";
 import firmwareApi from "@/services/api/firmware";
@@ -9,13 +11,11 @@ import { formatBytes, formatTimestamp, getSupportedEJSCores } from "@/utils";
 import { ROUTES } from "@/plugins/router";
 import Player from "@/views/Player/EmulatorJS/Player.vue";
 import { isNull } from "lodash";
-import { inject, onBeforeUnmount, onMounted, ref } from "vue";
+import { onBeforeUnmount, onMounted, ref } from "vue";
 import { useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { saveSave, saveState } from "./utils";
-import CacheDialog from "./CacheDialog.vue";
-import type { Emitter } from "mitt";
-import type { Events } from "@/types/emitter";
+import CacheDialog from "@/views/Player/EmulatorJS/CacheDialog.vue";
 import { getEmptyCoverImage } from "@/utils/covers";
 import { useDisplay } from "vuetify";
 
@@ -29,16 +29,17 @@ const auth = storeAuth();
 const romsStore = storeRoms();
 const rom = ref<DetailedRom | null>(null);
 const firmwareOptions = ref<FirmwareSchema[]>([]);
-const biosRef = ref<FirmwareSchema | null>(null);
-const saveRef = ref<SaveSchema | null>(null);
-const stateRef = ref<StateSchema | null>(null);
-const coreRef = ref<string | null>(null);
-const discRef = ref<number | null>(null);
+const selectedFirmware = ref<FirmwareSchema | null>(null);
+const selectedSave = ref<SaveSchema | null>(null);
+const openSaveSelector = ref(false);
+const selectedState = ref<StateSchema | null>(null);
+const openStateSelector = ref(false);
+const selectedCore = ref<string | null>(null);
+const selectedDisc = ref<number | null>(null);
 const supportedCores = ref<string[]>([]);
 const gameRunning = ref(false);
 const storedFSOP = localStorage.getItem("fullScreenOnPlay");
 const fullScreenOnPlay = ref(isNull(storedFSOP) ? true : storedFSOP === "true");
-const emitter = inject<Emitter<Events>>("emitter");
 
 // Functions
 function onPlay() {
@@ -50,21 +51,34 @@ function onPlay() {
     });
   }
 
+  gameRunning.value = true;
   window.EJS_fullscreenOnLoaded = fullScreenOnPlay.value;
-  window.EJS_pathtodata = "/assets/emulatorjs/data/";
-
-  const script = document.createElement("script");
-  script.src = "/assets/emulatorjs/data/loader.js";
 
-  script.onerror = () => {
-    window.EJS_pathtodata = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`;
-    const fallbackScript = document.createElement("script");
-    fallbackScript.src = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/loader.js`;
-    document.body.appendChild(fallbackScript);
-  };
+  const LOCAL_PATH = "/assets/emulatorjs/data/";
+  const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`;
 
-  document.body.appendChild(script);
-  gameRunning.value = true;
+  // 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) => {
+      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 onFullScreenChange() {
@@ -93,7 +107,7 @@ async function saveAndQuit() {
   const saveFile = window.EJS_emulator.gameManager.getSaveFile();
   await saveSave({
     rom: rom.value,
-    save: saveRef.value,
+    save: selectedSave.value,
     saveFile,
     screenshotFile,
   });
@@ -102,20 +116,42 @@ async function saveAndQuit() {
   window.history.back();
 }
 
-function saveSelected(save: SaveSchema) {
-  saveRef.value = save;
+function switchSaveSelector() {
+  openSaveSelector.value = !openSaveSelector.value;
+  openStateSelector.value = false;
+}
+
+function selectSave(save: SaveSchema) {
+  selectedSave.value = save;
   localStorage.setItem(
     `player:${rom.value?.platform_slug}:save_id`,
     save.id.toString(),
   );
+  openSaveSelector.value = false;
+}
+
+function unselectSave() {
+  selectedSave.value = null;
+  localStorage.removeItem(`player:${rom.value?.platform_slug}:save_id`);
+}
+
+function switchStateSelector() {
+  openStateSelector.value = !openStateSelector.value;
+  openSaveSelector.value = false;
 }
 
-function stateSelected(state: StateSchema) {
-  stateRef.value = state;
+function selectState(state: StateSchema) {
+  selectedState.value = state;
   localStorage.setItem(
     `player:${rom.value?.platform_slug}:state_id`,
     state.id.toString(),
   );
+  openStateSelector.value = false;
+}
+
+function unselectState() {
+  selectedState.value = null;
+  localStorage.removeItem(`player:${rom.value?.platform_slug}:state_id`);
 }
 
 onMounted(async () => {
@@ -132,14 +168,14 @@ onMounted(async () => {
   supportedCores.value = [...getSupportedEJSCores(rom.value.platform_slug)];
 
   // Load stored bios, save, state, and core
-  saveRef.value = rom.value.user_saves[0] ?? null;
-  stateRef.value = rom.value.user_states[0] ?? null;
+  selectedSave.value = rom.value.user_saves[0] ?? null;
+  selectedState.value = rom.value.user_states[0] ?? null;
 
   const storedBiosID = localStorage.getItem(
     `player:${rom.value.platform_slug}:bios_id`,
   );
   if (storedBiosID) {
-    biosRef.value =
+    selectedFirmware.value =
       firmwareOptions.value.find((f) => f.id === parseInt(storedBiosID)) ??
       null;
   }
@@ -148,24 +184,19 @@ onMounted(async () => {
     `player:${rom.value.platform_slug}:core`,
   );
   if (storedCore) {
-    coreRef.value = storedCore;
+    selectedCore.value = storedCore;
   } else {
     // Otherwise auto select first supported core
-    coreRef.value = supportedCores.value[0];
+    selectedCore.value = supportedCores.value[0];
   }
 
   const storedDisc = localStorage.getItem(`player:${rom.value.id}:disc`);
   if (storedDisc) {
-    discRef.value = parseInt(storedDisc);
+    selectedDisc.value = parseInt(storedDisc);
   }
-
-  emitter?.on("saveSelected", saveSelected);
-  emitter?.on("stateSelected", stateSelected);
 });
 
 onBeforeUnmount(async () => {
-  emitter?.off("saveSelected", saveSelected);
-  emitter?.off("stateSelected", stateSelected);
   window.EJS_emulator?.callEvent("exit");
 });
 </script>
@@ -183,20 +214,21 @@ onBeforeUnmount(async () => {
     >
       <player
         :rom="rom"
-        :state="stateRef"
-        :save="saveRef"
-        :bios="biosRef"
-        :core="coreRef"
-        :disc="discRef"
+        :state="selectedState"
+        :save="selectedSave"
+        :bios="selectedFirmware"
+        :core="selectedCore"
+        :disc="selectedDisc"
       />
     </v-col>
 
     <v-col
       cols="12"
-      :sm="!gameRunning ? 10 : 10"
+      sm="10"
       :md="!gameRunning ? 8 : 4"
       :xl="!gameRunning ? 6 : 2"
     >
+      <!-- Header -->
       <v-row class="px-3 mt-6" no-gutters>
         <v-col>
           <v-img
@@ -204,15 +236,17 @@ onBeforeUnmount(async () => {
             width="250"
             src="/assets/emulatorjs/powered_by_emulatorjs.png"
           />
-          <v-divider class="my-4 mt-8" />
+          <v-divider class="mb-4 mt-8" />
           <rom-list-item :rom="rom" with-filename with-size />
         </v-col>
       </v-row>
+
       <v-row v-if="!gameRunning" class="px-3 py-3" no-gutters>
         <v-col>
+          <!-- disc selector -->
           <v-select
             v-if="rom.multi"
-            v-model="discRef"
+            v-model="selectedDisc"
             class="my-1"
             hide-details
             rounded="0"
@@ -226,9 +260,10 @@ onBeforeUnmount(async () => {
               }))
             "
           />
+          <!-- core selector -->
           <v-select
             v-if="supportedCores.length > 1"
-            v-model="coreRef"
+            v-model="selectedCore"
             class="my-1"
             hide-details
             variant="outlined"
@@ -241,9 +276,10 @@ onBeforeUnmount(async () => {
               }))
             "
           />
+          <!-- bios selector -->
           <v-select
             v-if="firmwareOptions.length > 0"
-            v-model="biosRef"
+            v-model="selectedFirmware"
             class="my-1"
             hide-details
             variant="outlined"
@@ -256,140 +292,311 @@ onBeforeUnmount(async () => {
               })) ?? []
             "
           />
-          <v-row class="mt-2">
-            <v-col :cols="smAndDown ? 12 : 6">
-              <v-card v-if="stateRef" class="bg-toplayer transform-scale">
-                <v-card-text class="d-flex flex-row justify-end h-100">
-                  <v-col class="pa-0">
-                    <v-img
-                      cover
-                      height="100%"
-                      :src="
-                        stateRef.screenshot?.download_path ??
-                        getEmptyCoverImage(stateRef.file_name)
-                      "
-                    />
-                  </v-col>
-                  <v-col class="ml-4">
-                    <v-row class="text-h6">{{
-                      t("play.select-state").toUpperCase()
-                    }}</v-row>
-                    <v-row class="mt-4 flex-grow-0">{{
-                      stateRef.file_name
-                    }}</v-row>
-                    <v-row
-                      class="mt-6 d-flex flex-md-wrap ga-2 flex-grow-0"
-                      style="min-height: 20px"
-                    >
-                      <v-chip
-                        v-if="stateRef.emulator"
-                        size="x-small"
-                        color="orange"
-                        label
-                      >
-                        {{ stateRef.emulator }}
-                      </v-chip>
-                      <v-chip size="x-small" label>
-                        {{ formatBytes(stateRef.file_size_bytes) }}
-                      </v-chip>
-                      <v-chip size="x-small" label>
-                        {{ formatTimestamp(stateRef.updated_at) }}
-                      </v-chip>
-                      <v-btn
-                        class="w-100 mt-4"
-                        variant="outlined"
-                        size="large"
-                        @click="stateRef = null"
-                      >
-                        <v-icon>mdi-close-circle-outline</v-icon>
-                      </v-btn>
-                    </v-row>
-                  </v-col>
-                </v-card-text>
-              </v-card>
-              <v-row v-else>
+
+          <!-- save/satate selector -->
+          <v-row class="mt-4" no-gutters>
+            <!-- state selector -->
+            <v-col
+              :class="gameRunning || smAndDown ? 'mt-2' : 'pr-1'"
+              :cols="smAndDown ? 12 : 6"
+            >
+              <v-row no-gutters>
                 <v-col>
                   <v-btn
-                    class="w-100"
+                    block
                     variant="outlined"
                     size="large"
-                    @click="emitter?.emit('selectStateDialog', rom)"
+                    prepend-icon="mdi-file"
+                    :color="openStateSelector ? 'primary' : ''"
+                    @click="switchStateSelector"
                   >
-                    {{ t("play.select-state") }}
+                    {{
+                      selectedState
+                        ? t("play.change-state")
+                        : t("play.select-state")
+                    }}
                   </v-btn>
                 </v-col>
               </v-row>
-            </v-col>
-            <v-col :cols="smAndDown ? 12 : 6">
-              <v-card v-if="saveRef" class="bg-toplayer transform-scale">
-                <v-card-text class="d-flex flex-row justify-end h-100">
-                  <v-col class="pa-0">
-                    <v-img
-                      cover
-                      height="100%"
-                      :src="
-                        saveRef.screenshot?.download_path ??
-                        getEmptyCoverImage(saveRef.file_name)
-                      "
-                    />
-                  </v-col>
-                  <v-col class="ml-4">
-                    <v-row class="text-h6">{{
-                      t("play.select-save").toUpperCase()
-                    }}</v-row>
-                    <v-row class="mt-4 flex-grow-0">{{
-                      saveRef.file_name
-                    }}</v-row>
-                    <v-row
-                      class="mt-6 d-flex flex-md-wrap ga-2 flex-grow-0"
-                      style="min-height: 20px"
-                    >
-                      <v-chip
-                        v-if="saveRef.emulator"
-                        size="x-small"
-                        color="orange"
-                        label
-                      >
-                        {{ saveRef.emulator }}
-                      </v-chip>
-                      <v-chip size="x-small" label>
-                        {{ formatBytes(saveRef.file_size_bytes) }}
-                      </v-chip>
-                      <v-chip size="x-small" label>
-                        {{ formatTimestamp(saveRef.updated_at) }}
-                      </v-chip>
-                      <v-btn
-                        class="w-100 mt-4"
-                        variant="outlined"
-                        size="large"
-                        @click="saveRef = null"
-                      >
-                        <v-icon>mdi-close-circle-outline</v-icon>
-                      </v-btn>
+              <v-expand-transition>
+                <v-card
+                  v-if="selectedState"
+                  class="bg-toplayer transform-scale"
+                  :class="{ 'disabled-card': openSaveSelector }"
+                >
+                  <v-card-text class="px-2 pb-2">
+                    <v-row no-gutters>
+                      <v-col cols="6">
+                        <v-img
+                          rounded
+                          :src="
+                            selectedState.screenshot?.download_path ??
+                            getEmptyCoverImage(selectedState.file_name)
+                          "
+                        >
+                        </v-img>
+                      </v-col>
+                      <v-col class="pl-2" cols="6">
+                        <v-row class="text-caption" no-gutters>{{
+                          selectedState.file_name
+                        }}</v-row>
+                        <v-row class="ga-1" no-gutters>
+                          <v-col v-if="selectedState.emulator" cols="12">
+                            <v-chip size="x-small" color="orange" label>
+                              {{ selectedState.emulator }}
+                            </v-chip>
+                          </v-col>
+                          <v-col cols="12">
+                            <v-chip size="x-small" label>
+                              {{ formatBytes(selectedState.file_size_bytes) }}
+                            </v-chip>
+                          </v-col>
+                          <v-col cols="12">
+                            <v-chip size="x-small" label>
+                              Updated:
+                              {{ formatTimestamp(selectedState.updated_at) }}
+                            </v-chip>
+                          </v-col>
+                        </v-row>
+                      </v-col>
+                    </v-row>
+                    <v-row class="mt-2" no-gutters>
+                      <v-col>
+                        <v-btn
+                          block
+                          variant="outlined"
+                          @click="unselectState()"
+                        >
+                          <v-icon class="mr-2">mdi-close-circle-outline</v-icon
+                          >{{ t("play.deselect-state") }}
+                        </v-btn>
+                      </v-col>
                     </v-row>
-                  </v-col>
-                </v-card-text>
-              </v-card>
-              <v-row v-else>
+                  </v-card-text>
+                </v-card>
+              </v-expand-transition>
+            </v-col>
+
+            <!-- save selector -->
+            <v-col
+              :class="gameRunning || smAndDown ? 'mt-2' : 'pl-1'"
+              :cols="smAndDown ? 12 : 6"
+            >
+              <v-row no-gutters>
                 <v-col>
                   <v-btn
-                    class="w-100"
+                    block
                     variant="outlined"
+                    prepend-icon="mdi-content-save"
                     size="large"
-                    @click="emitter?.emit('selectSaveDialog', rom)"
+                    :color="openSaveSelector ? 'primary' : ''"
+                    @click="switchSaveSelector"
                   >
-                    {{ t("play.select-save") }}
+                    {{
+                      selectedSave
+                        ? t("play.change-save")
+                        : t("play.select-save")
+                    }}
                   </v-btn>
                 </v-col>
               </v-row>
+              <v-expand-transition>
+                <v-card
+                  v-if="selectedSave"
+                  class="bg-toplayer transform-scale"
+                  :class="{ 'disabled-card': openStateSelector }"
+                >
+                  <v-card-text class="px-2 pb-2">
+                    <v-row no-gutters>
+                      <v-col cols="6">
+                        <v-img
+                          rounded
+                          :src="
+                            selectedSave.screenshot?.download_path ??
+                            getEmptyCoverImage(selectedSave.file_name)
+                          "
+                        >
+                        </v-img>
+                      </v-col>
+                      <v-col class="pl-2" cols="6">
+                        <v-row class="text-caption" no-gutters>{{
+                          selectedSave.file_name
+                        }}</v-row>
+                        <v-row class="ga-1" no-gutters>
+                          <v-col v-if="selectedSave.emulator" cols="12">
+                            <v-chip size="x-small" color="orange" label>
+                              {{ selectedSave.emulator }}
+                            </v-chip>
+                          </v-col>
+                          <v-col cols="12">
+                            <v-chip size="x-small" label>
+                              {{ formatBytes(selectedSave.file_size_bytes) }}
+                            </v-chip>
+                          </v-col>
+                          <v-col cols="12">
+                            <v-chip size="x-small" label>
+                              Updated:
+                              {{ formatTimestamp(selectedSave.updated_at) }}
+                            </v-chip>
+                          </v-col>
+                        </v-row>
+                      </v-col>
+                    </v-row>
+                    <v-row class="mt-2" no-gutters>
+                      <v-col>
+                        <v-btn block variant="outlined" @click="unselectSave()">
+                          <v-icon class="mr-2">mdi-close-circle-outline</v-icon
+                          >{{ t("play.deselect-save") }}
+                        </v-btn>
+                      </v-col>
+                    </v-row>
+                  </v-card-text>
+                </v-card>
+              </v-expand-transition>
             </v-col>
           </v-row>
+
+          <!-- state display -->
+          <v-expand-transition>
+            <v-row v-if="openStateSelector" class="mt-2" no-gutters>
+              <v-col
+                cols="6"
+                sm="4"
+                class="pa-1"
+                v-if="rom.user_states.length > 0"
+                v-for="state in rom.user_states"
+              >
+                <v-hover v-slot="{ isHovering, props }">
+                  <v-card
+                    :style="{
+                      zIndex: selectedState?.id === state.id ? 11 : undefined,
+                    }"
+                    v-bind="props"
+                    class="bg-toplayer transform-scale"
+                    :class="{
+                      'on-hover': isHovering,
+                      'border-selected': selectedState?.id === state.id,
+                    }"
+                    :elevation="isHovering ? 20 : 3"
+                    @click="selectState(state)"
+                  >
+                    <v-card-text class="pa-2">
+                      <v-row no-gutters>
+                        <v-col cols="12">
+                          <v-img
+                            rounded
+                            :src="
+                              state.screenshot?.download_path ??
+                              getEmptyCoverImage(state.file_name)
+                            "
+                          >
+                          </v-img>
+                        </v-col>
+                      </v-row>
+                      <v-row class="py-2 text-caption" no-gutters>{{
+                        state.file_name
+                      }}</v-row>
+                      <v-row class="ga-1" no-gutters>
+                        <v-col v-if="state.emulator" cols="12">
+                          <v-chip size="x-small" color="orange" label>
+                            {{ state.emulator }}
+                          </v-chip>
+                        </v-col>
+                        <v-col cols="12">
+                          <v-chip size="x-small" label>
+                            {{ formatBytes(state.file_size_bytes) }}
+                          </v-chip>
+                        </v-col>
+                        <v-col cols="12">
+                          <v-chip size="x-small" label>
+                            Updated: {{ formatTimestamp(state.updated_at) }}
+                          </v-chip>
+                        </v-col>
+                      </v-row>
+                    </v-card-text>
+                  </v-card>
+                </v-hover>
+              </v-col>
+              <v-col v-else class="pa-1 mt-1">
+                <empty-states />
+              </v-col>
+            </v-row>
+          </v-expand-transition>
+
+          <!-- save display -->
+          <v-expand-transition>
+            <v-row v-if="openSaveSelector" class="mt-2" no-gutters>
+              <v-col
+                cols="6"
+                sm="4"
+                class="pa-1"
+                v-if="rom.user_saves.length > 0"
+                v-for="save in rom.user_saves"
+              >
+                <v-hover v-slot="{ isHovering, props }">
+                  <v-card
+                    :style="{
+                      zIndex: selectedSave?.id === save.id ? 11 : undefined,
+                    }"
+                    v-bind="props"
+                    class="bg-toplayer transform-scale"
+                    :class="{
+                      'on-hover': isHovering,
+                      'border-selected': selectedSave?.id === save.id,
+                    }"
+                    :elevation="isHovering ? 20 : 3"
+                    @click="selectSave(save)"
+                  >
+                    <v-card-text class="pa-2">
+                      <v-row no-gutters>
+                        <v-col cols="12">
+                          <v-img
+                            rounded
+                            :src="
+                              save.screenshot?.download_path ??
+                              getEmptyCoverImage(save.file_name)
+                            "
+                          >
+                          </v-img>
+                        </v-col>
+                      </v-row>
+                      <v-row class="py-2 text-caption" no-gutters>{{
+                        save.file_name
+                      }}</v-row>
+                      <v-row class="ga-1" no-gutters>
+                        <v-col v-if="save.emulator" cols="12">
+                          <v-chip size="x-small" color="orange" label>
+                            {{ save.emulator }}
+                          </v-chip>
+                        </v-col>
+                        <v-col cols="12">
+                          <v-chip size="x-small" label>
+                            {{ formatBytes(save.file_size_bytes) }}
+                          </v-chip>
+                        </v-col>
+                        <v-col cols="12">
+                          <v-chip size="x-small" label>
+                            Updated: {{ formatTimestamp(save.updated_at) }}
+                          </v-chip>
+                        </v-col>
+                      </v-row>
+                    </v-card-text>
+                  </v-card>
+                </v-hover>
+              </v-col>
+              <v-col v-else class="pa-1 mt-1">
+                <empty-saves />
+              </v-col>
+            </v-row>
+          </v-expand-transition>
         </v-col>
       </v-row>
-      <v-row class="px-3 py-3 text-center" no-gutters>
+
+      <!-- Action buttons -->
+      <v-row class="px-3 py-2 text-center" no-gutters>
         <v-col>
           <v-row v-if="!gameRunning" class="align-center" no-gutters>
-            <v-col>
+            <v-col :class="smAndDown ? '' : 'pr-1'">
               <v-btn
                 block
                 size="large"
@@ -407,7 +614,7 @@ onBeforeUnmount(async () => {
             </v-col>
             <v-col
               :cols="gameRunning || smAndDown ? 12 : 8"
-              :class="gameRunning || smAndDown ? 'mt-2' : 'ml-4'"
+              :class="gameRunning || smAndDown ? 'mt-2' : 'pl-1'"
             >
               <v-btn
                 color="primary"
@@ -490,4 +697,8 @@ onBeforeUnmount(async () => {
     height: calc(100vh - 55px);
   }
 }
+
+.disabled-card {
+  opacity: 0.2;
+}
 </style>
diff --git a/frontend/src/views/Player/EmulatorJS/CacheDialog.vue b/frontend/src/views/Player/EmulatorJS/CacheDialog.vue
index d16ea0607c..7250a2651d 100644
--- a/frontend/src/views/Player/EmulatorJS/CacheDialog.vue
+++ b/frontend/src/views/Player/EmulatorJS/CacheDialog.vue
@@ -37,8 +37,10 @@ function closeDialog() {
       </v-row>
     </template>
     <template #content>
-      <div class="text-h6 pa-4">{{ t("play.clear-cache-title") }}</div>
-      <div class="text-body-1 px-4 pb-4">
+      <div class="text-h6 text-center pa-4">
+        {{ t("play.clear-cache-title") }}
+      </div>
+      <div class="text-body-1 text-center px-4 pb-4">
         <strong>{{ t("play.clear-cache-warning") }}</strong>
         <br />
         {{ t("play.clear-cache-description") }}
diff --git a/frontend/src/views/Player/EmulatorJS/Player.vue b/frontend/src/views/Player/EmulatorJS/Player.vue
index def6510ca6..54bd47f47a 100644
--- a/frontend/src/views/Player/EmulatorJS/Player.vue
+++ b/frontend/src/views/Player/EmulatorJS/Player.vue
@@ -180,7 +180,7 @@ async function loadSave(save: SaveSchema) {
     loadEmulatorJSSave(new Uint8Array(data));
     displayMessage("Save loaded from server", {
       duration: 3000,
-      icon: "mdi-cloud-upload-outline",
+      icon: "mdi-cloud-download-outline",
     });
     return;
   }
@@ -231,7 +231,7 @@ async function loadState(state: StateSchema) {
     loadEmulatorJSState(new Uint8Array(data));
     displayMessage("State loaded from server", {
       duration: 3000,
-      icon: "mdi-cloud-upload-outline",
+      icon: "mdi-cloud-download-outline",
     });
     return;
   }


Reviews:
user: login: gantoine
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T20:43:40Z
user: login: gantoine
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T02:37:56Z
user: login: zurdi15
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T07:09:35Z

Review comments:
diff_hunk: @@ -50,21 +50,34 @@ function onPlay() {
     });
   }
 
+  gameRunning.value = true;
   window.EJS_fullscreenOnLoaded = fullScreenOnPlay.value;
-  window.EJS_pathtodata = "/assets/emulatorjs/data/";
-
-  const script = document.createElement("script");
-  script.src = "/assets/emulatorjs/data/loader.js";
 
-  script.onerror = () => {
-    window.EJS_pathtodata = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`;
-    const fallbackScript = document.createElement("script");
-    fallbackScript.src = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/loader.js`;
-    document.body.appendChild(fallbackScript);
-  };
+  const LOCAL_PATH = "/assets/emulatorjs/data/";
+  const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`;
 
-  document.body.appendChild(script);
-  gameRunning.value = true;
+  // 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) => {
+      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);
+    });
path: frontend/src/views/Player/EmulatorJS/Base.vue
user: login: gantoine
body: not sure what's happening here, will have to take a closer look before this is merged
created_at: 2025-05-14T20:43:40Z
author_association: MEMBER
diff_hunk: @@ -50,21 +50,34 @@ function onPlay() {
     });
   }
 
+  gameRunning.value = true;
   window.EJS_fullscreenOnLoaded = fullScreenOnPlay.value;
-  window.EJS_pathtodata = "/assets/emulatorjs/data/";
-
-  const script = document.createElement("script");
-  script.src = "/assets/emulatorjs/data/loader.js";
 
-  script.onerror = () => {
-    window.EJS_pathtodata = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`;
-    const fallbackScript = document.createElement("script");
-    fallbackScript.src = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/loader.js`;
-    document.body.appendChild(fallbackScript);
-  };
+  const LOCAL_PATH = "/assets/emulatorjs/data/";
+  const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`;
 
-  document.body.appendChild(script);
-  gameRunning.value = true;
+  // 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) => {
+      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);
+    });
path: frontend/src/views/Player/EmulatorJS/Base.vue
user: login: zurdi15
body: It basically makes a double check that the local loader.js file is correct. It was happening to me that sometimes somehow it was being loaded but incorrectly and it didn't fallback to the CDN but instead it was raising an error.
created_at: 2025-05-15T07:09:35Z
author_association: MEMBER

PR comments:
user: login: trunk-io[bot]
created_at: 2025-05-14T17:37:03Z
body: Running Code Quality on PRs by uploading data to Trunk will soon be removed. You can still run checks on your PRs using [trunk-action](https://github.com/trunk-io/trunk-action) - see the [migration guide](https://docs.trunk.io/code-quality/setup-and-installation/prevent-new-issues/migration-guide) for more information.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-14T17:39:55Z
body: ## Test Results
93 tests   93 ✅  27s ⏱️
 1 suites   0 💤
 1 files     0 ❌

Results for commit 0daf8a7b.

[test-results]:data:application/gzip;base64,H4sIABpWJ2gC/12MzQ7CIBAGX6Xh7AEEQvFlzJayyca2GH5OxneXVsXW7GVnvmQeDGnyiV06cepYKpQbjCVCprBUPJvKdcnrZuUXrqk492dudK+GN4FA00H4GEP8mFiWVlz/Q/Atfr2Nd7mN9zUX5plyBcZHwB7MAKi547IX1iqBXoIwOAqltB2srseeL/vOkKQAAQAA

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR