Merge pull request #1790 from rommapp/emujs-save-file-fix

Save and state UI and emulation overhaul
This commit is contained in:
Georges-Antoine Assi
2025-03-30 15:01:36 -04:00
committed by GitHub
58 changed files with 2152 additions and 1319 deletions

View File

@@ -87,6 +87,7 @@ def get_supported_platforms(request: Request) -> list[PlatformSchema]:
"rom_count": 0,
"created_at": now,
"updated_at": now,
"fs_size_bytes": 0,
}
if platform["name"] in db_platforms_map:

View File

@@ -1,5 +1,4 @@
from datetime import datetime
from typing import TypedDict
from .base import BaseModel
@@ -27,27 +26,11 @@ class ScreenshotSchema(BaseAsset):
pass
class UploadedScreenshotsResponse(TypedDict):
uploaded: int
screenshots: list[ScreenshotSchema]
merged_screenshots: list[str]
class SaveSchema(BaseAsset):
emulator: str | None
screenshot: ScreenshotSchema | None
class UploadedSavesResponse(TypedDict):
uploaded: int
saves: list[SaveSchema]
class StateSchema(BaseAsset):
emulator: str | None
screenshot: ScreenshotSchema | None
class UploadedStatesResponse(TypedDict):
uploaded: int
states: list[StateSchema]

View File

@@ -29,7 +29,7 @@ class PlatformSchema(BaseModel):
aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO
created_at: datetime
updated_at: datetime
filesystem_size_bytes: int
fs_size_bytes: int
class Config:
from_attributes = True

View File

@@ -1,14 +1,13 @@
from datetime import datetime, timezone
from decorators.auth import protected_route
from endpoints.responses import MessageResponse
from endpoints.responses.assets import SaveSchema, UploadedSavesResponse
from endpoints.responses.assets import SaveSchema
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from fastapi import File, HTTPException, Request, UploadFile, status
from fastapi import HTTPException, Request, UploadFile, status
from handler.auth.constants import Scope
from handler.database import db_rom_handler, db_save_handler, db_screenshot_handler
from handler.filesystem import fs_asset_handler
from handler.scan_handler import scan_save
from handler.scan_handler import scan_save, scan_screenshot
from logger.logger import log
from utils.router import APIRouter
@@ -19,123 +18,159 @@ router = APIRouter(
@protected_route(router.post, "", [Scope.ASSETS_WRITE])
def add_saves(
async def add_save(
request: Request,
rom_id: int,
saves: list[UploadFile] = File(...), # noqa: B008
emulator: str | None = None,
) -> UploadedSavesResponse:
) -> SaveSchema:
data = await request.form()
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
current_user = request.user
log.info(f"Uploading saves to {rom.name}")
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, emulator=emulator
)
for save in saves:
if not save.filename:
log.error("Save file has no filename")
continue
fs_asset_handler.write_file(file=save, path=saves_path)
# Scan or update save
scanned_save = scan_save(
file_name=save.filename,
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
emulator=emulator,
if "saveFile" not in data:
log.error("No save file provided")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="No save file provided"
)
db_save = db_save_handler.get_save_by_filename(
rom_id=rom.id, user_id=current_user.id, file_name=save.filename
)
if db_save:
db_save_handler.update_save(
db_save.id, {"file_size_bytes": scanned_save.file_size_bytes}
)
continue
saveFile: UploadFile = data["saveFile"] # type: ignore
if not saveFile.filename:
log.error("Save file has no filename")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Save file has no filename"
)
fs_asset_handler.write_file(file=saveFile, path=saves_path)
# Scan or update save
scanned_save = scan_save(
file_name=saveFile.filename,
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
emulator=emulator,
)
db_save = db_save_handler.get_save_by_filename(
user_id=request.user.id, rom_id=rom.id, file_name=saveFile.filename
)
if db_save:
db_save = db_save_handler.update_save(
db_save.id, {"file_size_bytes": scanned_save.file_size_bytes}
)
else:
scanned_save.rom_id = rom.id
scanned_save.user_id = current_user.id
scanned_save.user_id = request.user.id
scanned_save.emulator = emulator
db_save_handler.add_save(scanned_save)
db_save = db_save_handler.add_save(save=scanned_save)
# Set the last played time for the current user
rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
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
)
fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
# Scan or update screenshot
scanned_screenshot = scan_screenshot(
file_name=screenshotFile.filename,
user=request.user,
platform_fs_slug=rom.platform_slug,
)
db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename
)
if db_screenshot:
db_screenshot = db_screenshot_handler.update_screenshot(
db_screenshot.id,
{"file_size_bytes": scanned_screenshot.file_size_bytes},
)
else:
scanned_screenshot.rom_id = rom.id
scanned_screenshot.user_id = request.user.id
db_screenshot = db_screenshot_handler.add_screenshot(
screenshot=scanned_screenshot
)
# Set the last played time for the current user
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=request.user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(rom_id=rom.id, user_id=request.user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
)
# Refetch the rom to get updated saves
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
return {
"uploaded": len(saves),
"saves": [
SaveSchema.model_validate(s)
for s in rom.saves
if s.user_id == current_user.id
],
}
return SaveSchema.model_validate(db_save)
# @protected_route(router.get, "", [Scope.ASSETS_READ])
# def get_saves(request: Request) -> MessageResponse:
# pass
@protected_route(router.get, "", [Scope.ASSETS_READ])
def get_saves(
request: Request, rom_id: int | None = None, platform_id: int | None = None
) -> list[SaveSchema]:
saves = db_save_handler.get_saves(
user_id=request.user.id, rom_id=rom_id, platform_id=platform_id
)
return [SaveSchema.model_validate(save) for save in saves]
# @protected_route(router.get, "/{id}", [Scope.ASSETS_READ])
# def get_save(request: Request, id: int) -> MessageResponse:
# pass
@protected_route(router.get, "/{id}", [Scope.ASSETS_READ])
def get_save(request: Request, id: int) -> SaveSchema:
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
error = f"Save with ID {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
return SaveSchema.model_validate(save)
@protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE])
async def update_save(request: Request, id: int) -> SaveSchema:
data = await request.form()
db_save = db_save_handler.get_save(id)
db_save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not db_save:
error = f"Save with ID {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
if db_save.user_id != request.user.id:
error = "You are not authorized to update this save"
log.error(error)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
if "file" in data:
file: UploadFile = data["file"] # type: ignore
fs_asset_handler.write_file(file=file, path=db_save.file_path)
db_save_handler.update_save(db_save.id, {"file_size_bytes": file.size})
if "saveFile" in data:
saveFile: UploadFile = data["saveFile"] # type: ignore
fs_asset_handler.write_file(file=saveFile, path=db_save.file_path)
db_save = db_save_handler.update_save(
db_save.id, {"file_size_bytes": saveFile.size}
)
# Set the last played time for the current user
current_user = request.user
rom_user = db_rom_handler.get_rom_user(db_save.rom_id, current_user.id)
rom_user = db_rom_handler.get_rom_user(db_save.rom_id, request.user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(db_save.rom_id, current_user.id)
rom_user = db_rom_handler.add_rom_user(db_save.rom_id, request.user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
)
# Refetch the save to get updated fields
db_save = db_save_handler.get_save(id)
return SaveSchema.model_validate(db_save)
@protected_route(router.post, "/delete", [Scope.ASSETS_WRITE])
async def delete_saves(request: Request) -> MessageResponse:
async def delete_saves(request: Request) -> list[int]:
data: dict = await request.json()
save_ids: list = data["saves"]
delete_from_fs: list = data["delete_from_fs"]
if not save_ids:
error = "No saves were provided"
@@ -143,44 +178,33 @@ async def delete_saves(request: Request) -> MessageResponse:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
for save_id in save_ids:
save = db_save_handler.get_save(save_id)
save = db_save_handler.get_save(user_id=request.user.id, id=save_id)
if not save:
error = f"Save with ID {save_id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
if save.user_id != request.user.id:
error = "You are not authorized to delete this save"
log.error(error)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
db_save_handler.delete_save(save_id)
if save_id in delete_from_fs:
log.info(f"Deleting {save.file_name} from filesystem")
try:
fs_asset_handler.remove_file(
file_name=save.file_name, file_path=save.file_path
)
except FileNotFoundError as exc:
error = f"Save file {save.file_name} not found for platform {save.rom.platform_slug}"
log.error(error)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=error
) from exc
log.info(f"Deleting {save.file_name} from filesystem")
try:
fs_asset_handler.remove_file(
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}"
log.error(error)
if save.screenshot:
db_screenshot_handler.delete_screenshot(save.screenshot.id)
if delete_from_fs:
try:
fs_asset_handler.remove_file(
file_name=save.screenshot.file_name,
file_path=save.screenshot.file_path,
)
except FileNotFoundError:
error = f"Screenshot file {save.screenshot.file_name} not found for save {save.file_name}"
log.error(error)
try:
fs_asset_handler.remove_file(
file_name=save.screenshot.file_name,
file_path=save.screenshot.file_path,
)
except FileNotFoundError:
error = f"Screenshot file {save.screenshot.file_name} not found for save {save.file_name}"
log.error(error)
return {"msg": f"Successfully deleted {len(save_ids)} saves"}
return save_ids

View File

@@ -1,7 +1,7 @@
from decorators.auth import protected_route
from endpoints.responses.assets import ScreenshotSchema, UploadedScreenshotsResponse
from endpoints.responses.assets import ScreenshotSchema
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from fastapi import File, Request, UploadFile
from fastapi import HTTPException, Request, UploadFile, status
from handler.auth.constants import Scope
from handler.database import db_rom_handler, db_screenshot_handler
from handler.filesystem import fs_asset_handler
@@ -16,12 +16,13 @@ router = APIRouter(
@protected_route(router.post, "", [Scope.ASSETS_WRITE])
def add_screenshots(
async def add_screenshot(
request: Request,
rom_id: int,
screenshots: list[UploadFile] = File(...), # noqa: B008
) -> UploadedScreenshotsResponse:
rom = db_rom_handler.get_rom(rom_id)
) -> ScreenshotSchema:
data = await request.form()
rom = db_rom_handler.get_rom(id=rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
@@ -32,43 +33,52 @@ def add_screenshots(
user=request.user, platform_fs_slug=rom.platform_slug
)
for screenshot in screenshots:
if not screenshot.filename:
log.warning("Skipping empty screenshot")
continue
fs_asset_handler.write_file(file=screenshot, path=screenshots_path)
# Scan or update screenshot
scanned_screenshot = scan_screenshot(
file_name=screenshot.filename,
user=request.user,
platform_fs_slug=rom.platform_slug,
if "screenshotFile" not in data:
log.error("No screenshot file provided")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No screenshot file provided",
)
db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
rom_id=rom.id, user_id=current_user.id, file_name=screenshot.filename
)
if db_screenshot:
db_screenshot_handler.update_screenshot(
db_screenshot.id,
{"file_size_bytes": scanned_screenshot.file_size_bytes},
)
continue
screenshotFile: UploadFile = data["screenshotFile"] # type: ignore
if not screenshotFile.filename:
log.error("Screenshot file has no filename")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Screenshot file has no filename",
)
if not screenshotFile.filename:
log.warning("Skipping empty screenshot")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Screenshot has no filename"
)
fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
# Scan or update screenshot
scanned_screenshot = scan_screenshot(
file_name=screenshotFile.filename,
user=request.user,
platform_fs_slug=rom.platform_slug,
)
db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
rom_id=rom.id, user_id=current_user.id, file_name=screenshotFile.filename
)
if db_screenshot:
db_screenshot = db_screenshot_handler.update_screenshot(
db_screenshot.id,
{"file_size_bytes": scanned_screenshot.file_size_bytes},
)
else:
scanned_screenshot.rom_id = rom.id
scanned_screenshot.user_id = current_user.id
db_screenshot_handler.add_screenshot(scanned_screenshot)
db_screenshot = db_screenshot_handler.add_screenshot(
screenshot=scanned_screenshot
)
rom = db_rom_handler.get_rom(rom_id)
rom = db_rom_handler.get_rom(id=rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
return {
"uploaded": len(screenshots),
"screenshots": [
ScreenshotSchema.model_validate(s)
for s in rom.screenshots
if s.user_id == current_user.id
],
"merged_screenshots": rom.merged_screenshots,
}
return ScreenshotSchema.model_validate(db_screenshot)

View File

@@ -1,14 +1,13 @@
from datetime import datetime, timezone
from decorators.auth import protected_route
from endpoints.responses import MessageResponse
from endpoints.responses.assets import StateSchema, UploadedStatesResponse
from endpoints.responses.assets import StateSchema
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from fastapi import File, HTTPException, Request, UploadFile, status
from fastapi import HTTPException, Request, UploadFile, status
from handler.auth.constants import Scope
from handler.database import db_rom_handler, db_screenshot_handler, db_state_handler
from handler.filesystem import fs_asset_handler
from handler.scan_handler import scan_state
from handler.scan_handler import scan_screenshot, scan_state
from logger.logger import log
from utils.router import APIRouter
@@ -19,121 +18,159 @@ router = APIRouter(
@protected_route(router.post, "", [Scope.ASSETS_WRITE])
def add_states(
async def add_state(
request: Request,
rom_id: int,
states: list[UploadFile] = File(...), # noqa: B008
emulator: str | None = None,
) -> UploadedStatesResponse:
) -> StateSchema:
data = await request.form()
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
current_user = request.user
log.info(f"Uploading states to {rom.name}")
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, emulator=emulator
)
for state in states:
if not state.filename:
log.warning("Skipping file with no filename")
continue
fs_asset_handler.write_file(file=state, path=states_path)
# Scan or update state
scanned_state = scan_state(
file_name=state.filename,
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
emulator=emulator,
if "stateFile" not in data:
log.error("No state file provided")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="No state file provided"
)
db_state = db_state_handler.get_state_by_filename(
rom_id=rom.id, user_id=current_user.id, file_name=state.filename
)
if db_state:
db_state_handler.update_state(
db_state.id, {"file_size_bytes": scanned_state.file_size_bytes}
)
continue
stateFile: UploadFile = data["stateFile"] # type: ignore
if not stateFile.filename:
log.error("State file has no filename")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="State file has no filename"
)
fs_asset_handler.write_file(file=stateFile, path=states_path)
# Scan or update state
scanned_state = scan_state(
file_name=stateFile.filename,
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
emulator=emulator,
)
db_state = db_state_handler.get_state_by_filename(
user_id=request.user.id, rom_id=rom.id, file_name=stateFile.filename
)
if db_state:
db_state = db_state_handler.update_state(
db_state.id, {"file_size_bytes": scanned_state.file_size_bytes}
)
else:
scanned_state.rom_id = rom.id
scanned_state.user_id = current_user.id
scanned_state.user_id = request.user.id
scanned_state.emulator = emulator
db_state_handler.add_state(scanned_state)
db_state = db_state_handler.add_state(state=scanned_state)
# Set the last played time for the current user
rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
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
)
fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
# Scan or update screenshot
scanned_screenshot = scan_screenshot(
file_name=screenshotFile.filename,
user=request.user,
platform_fs_slug=rom.platform_slug,
)
db_screenshot = db_screenshot_handler.get_screenshot_by_filename(
rom_id=rom.id, user_id=request.user.id, file_name=screenshotFile.filename
)
if db_screenshot:
db_screenshot = db_screenshot_handler.update_screenshot(
db_screenshot.id,
{"file_size_bytes": scanned_screenshot.file_size_bytes},
)
else:
scanned_screenshot.rom_id = rom.id
scanned_screenshot.user_id = request.user.id
db_screenshot = db_screenshot_handler.add_screenshot(
screenshot=scanned_screenshot
)
# Set the last played time for the current user
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=request.user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(rom_id=rom.id, user_id=request.user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
)
# Refetch the rom to get updated states
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
return {
"uploaded": len(states),
"states": [
StateSchema.model_validate(s)
for s in rom.states
if s.user_id == current_user.id
],
}
return StateSchema.model_validate(db_state)
# @protected_route(router.get, "", [Scope.ASSETS_READ])
# def get_states(request: Request) -> MessageResponse:
# pass
@protected_route(router.get, "", [Scope.ASSETS_READ])
def get_states(
request: Request, rom_id: int | None = None, platform_id: int | None = None
) -> list[StateSchema]:
states = db_state_handler.get_states(
user_id=request.user.id, rom_id=rom_id, platform_id=platform_id
)
return [StateSchema.model_validate(state) for state in states]
# @protected_route(router.get, "/{id}", [Scope.ASSETS_READ])
# def get_state(request: Request, id: int) -> MessageResponse:
# pass
@protected_route(router.get, "/{id}", [Scope.ASSETS_READ])
def get_state(request: Request, id: int) -> StateSchema:
state = db_state_handler.get_state(user_id=request.user.id, id=id)
if not state:
error = f"State with ID {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
return StateSchema.model_validate(state)
@protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE])
async def update_state(request: Request, id: int) -> StateSchema:
data = await request.form()
db_state = db_state_handler.get_state(id)
db_state = db_state_handler.get_state(user_id=request.user.id, id=id)
if not db_state:
error = f"Save with ID {id} not found"
error = f"State with ID {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
if db_state.user_id != request.user.id:
error = "You are not authorized to update this save state"
log.error(error)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
if "file" in data:
file: UploadFile = data["file"] # type: ignore
fs_asset_handler.write_file(file=file, path=db_state.file_path)
db_state_handler.update_state(db_state.id, {"file_size_bytes": file.size})
if "stateFile" in data:
stateFile: UploadFile = data["stateFile"] # type: ignore
fs_asset_handler.write_file(file=stateFile, path=db_state.file_path)
db_state = db_state_handler.update_state(
db_state.id, {"file_size_bytes": stateFile.size}
)
# Set the last played time for the current user
current_user = request.user
rom_user = db_rom_handler.get_rom_user(db_state.rom_id, current_user.id)
rom_user = db_rom_handler.get_rom_user(db_state.rom_id, request.user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(db_state.rom_id, current_user.id)
rom_user = db_rom_handler.add_rom_user(db_state.rom_id, request.user.id)
db_rom_handler.update_rom_user(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
)
db_state = db_state_handler.get_state(id)
# Refetch the state to get updated fields
return StateSchema.model_validate(db_state)
@protected_route(router.post, "/delete", [Scope.ASSETS_WRITE])
async def delete_states(request: Request) -> MessageResponse:
async def delete_states(request: Request) -> list[int]:
data: dict = await request.json()
state_ids: list = data["states"]
delete_from_fs: list = data["delete_from_fs"]
if not state_ids:
error = "No states were provided"
@@ -141,43 +178,33 @@ async def delete_states(request: Request) -> MessageResponse:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
for state_id in state_ids:
state = db_state_handler.get_state(state_id)
state = db_state_handler.get_state(user_id=request.user.id, id=state_id)
if not state:
error = f"Save with ID {state_id} not found"
error = f"State with ID {state_id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
if state.user_id != request.user.id:
error = "You are not authorized to delete this save state"
log.error(error)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
db_state_handler.delete_state(state_id)
log.info(f"Deleting {state.file_name} from filesystem")
if state_id in delete_from_fs:
log.info(f"Deleting {state.file_name} from filesystem")
try:
fs_asset_handler.remove_file(
file_name=state.file_name, file_path=state.file_path
)
except FileNotFoundError as exc:
error = f"Save file {state.file_name} not found for platform {state.rom.platform_slug}"
log.error(error)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=error
) from exc
try:
fs_asset_handler.remove_file(
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}"
log.error(error)
if state.screenshot:
db_screenshot_handler.delete_screenshot(state.screenshot.id)
if delete_from_fs:
try:
fs_asset_handler.remove_file(
file_name=state.screenshot.file_name,
file_path=state.screenshot.file_path,
)
except FileNotFoundError:
error = f"Screenshot file {state.screenshot.file_name} not found for state {state.file_name}"
log.error(error)
try:
fs_asset_handler.remove_file(
file_name=state.screenshot.file_name,
file_path=state.screenshot.file_path,
)
except FileNotFoundError:
error = f"Screenshot file {state.screenshot.file_name} not found for state {state.file_name}"
log.error(error)
return {"msg": f"Successfully deleted {len(state_ids)} states"}
return state_ids

View File

@@ -13,21 +13,21 @@ def test_delete_saves(client, access_token, save):
response = client.post(
"/api/saves/delete",
headers={"Authorization": f"Bearer {access_token}"},
json={"saves": [save.id], "delete_from_fs": []},
json={"saves": [save.id]},
)
assert response.status_code == 200
body = response.json()
assert body["msg"] == "Successfully deleted 1 saves"
assert len(body) == 1
def test_delete_states(client, access_token, state):
response = client.post(
"/api/states/delete",
headers={"Authorization": f"Bearer {access_token}"},
json={"states": [state.id], "delete_from_fs": []},
json={"states": [state.id]},
)
assert response.status_code == 200
body = response.json()
assert body["msg"] == "Successfully deleted 1 states"
assert len(body) == 1

View File

@@ -14,12 +14,12 @@ class DBSavesHandler(DBBaseHandler):
return session.merge(save)
@begin_session
def get_save(self, id: int, session: Session = None) -> Save | None:
def get_save(self, user_id: int, id: int, session: Session = None) -> Save | None:
return session.get(Save, id)
@begin_session
def get_save_by_filename(
self, rom_id: int, user_id: int, file_name: str, session: Session = None
self, user_id: int, rom_id: int, file_name: str, session: Session = None
) -> Save | None:
return session.scalars(
select(Save)
@@ -27,6 +27,24 @@ class DBSavesHandler(DBBaseHandler):
.limit(1)
).first()
@begin_session
def get_saves(
self,
user_id: int,
rom_id: int | None = None,
platform_id: int | None = None,
session: Session = None,
) -> Sequence[Save]:
query = select(Save).filter_by(user_id=user_id)
if rom_id:
query = query.filter_by(rom_id=rom_id)
if platform_id:
query = query.filter_by(platform_id=platform_id)
return session.scalars(query).all()
@begin_session
def update_save(self, id: int, data: dict, session: Session = None) -> Save:
session.execute(

View File

@@ -14,18 +14,36 @@ class DBStatesHandler(DBBaseHandler):
return session.merge(state)
@begin_session
def get_state(self, id: int, session: Session = None) -> State | None:
return session.get(State, id)
def get_state(self, user_id: int, id: int, session: Session = None) -> State | None:
return session.scalar(select(State).filter_by(user_id=user_id, id=id).limit(1))
@begin_session
def get_state_by_filename(
self, rom_id: int, user_id: int, file_name: str, session: Session = None
self, user_id: int, rom_id: int, file_name: str, session: Session = None
) -> State | None:
return session.scalars(
return session.scalar(
select(State)
.filter_by(rom_id=rom_id, user_id=user_id, file_name=file_name)
.limit(1)
).first()
)
@begin_session
def get_states(
self,
user_id: int,
rom_id: int | None = None,
platform_id: int | None = None,
session: Session = None,
) -> Sequence[State]:
query = select(State).filter_by(user_id=user_id)
if rom_id:
query = query.filter_by(rom_id=rom_id)
if platform_id:
query = query.filter_by(platform_id=platform_id)
return session.scalars(query).all()
@begin_session
def update_state(self, id: int, data: dict, session: Session = None) -> State:

View File

@@ -137,12 +137,12 @@ def test_saves(save: Save, platform: Platform, admin_user: User):
assert rom is not None
assert len(rom.saves) == 2
new_save = db_save_handler.get_save(rom.saves[0].id)
new_save = db_save_handler.get_save(user_id=admin_user.id, id=rom.saves[0].id)
assert new_save is not None
assert new_save.file_name == "test_save.sav"
db_save_handler.update_save(new_save.id, {"file_name": "test_save_2.sav"})
new_save = db_save_handler.get_save(new_save.id)
new_save = db_save_handler.get_save(user_id=admin_user.id, id=new_save.id)
assert new_save is not None
assert new_save.file_name == "test_save_2.sav"
@@ -167,22 +167,22 @@ def test_states(state: State, platform: Platform, admin_user: User):
)
)
rom = db_rom_handler.get_rom(state.rom_id)
rom = db_rom_handler.get_rom(id=state.rom_id)
assert rom is not None
assert len(rom.states) == 2
new_state = db_state_handler.get_state(rom.states[0].id)
new_state = db_state_handler.get_state(user_id=admin_user.id, id=rom.states[0].id)
assert new_state is not None
assert new_state.file_name == "test_state.state"
db_state_handler.update_state(new_state.id, {"file_name": "test_state_2.state"})
new_state = db_state_handler.get_state(new_state.id)
new_state = db_state_handler.get_state(user_id=admin_user.id, id=new_state.id)
assert new_state is not None
assert new_state.file_name == "test_state_2.state"
db_state_handler.delete_state(new_state.id)
db_state_handler.delete_state(id=new_state.id)
rom = db_rom_handler.get_rom(state.rom_id)
rom = db_rom_handler.get_rom(id=state.rom_id)
assert rom is not None
assert len(rom.states) == 1
@@ -205,19 +205,19 @@ def test_screenshots(screenshot: Screenshot, platform: Platform, admin_user: Use
assert rom is not None
assert len(rom.screenshots) == 2
new_screenshot = db_screenshot_handler.get_screenshot(rom.screenshots[0].id)
new_screenshot = db_screenshot_handler.get_screenshot(id=rom.screenshots[0].id)
assert new_screenshot is not None
assert new_screenshot.file_name == "test_screenshot.png"
db_screenshot_handler.update_screenshot(
new_screenshot.id, {"file_name": "test_screenshot_2.png"}
)
new_screenshot = db_screenshot_handler.get_screenshot(new_screenshot.id)
new_screenshot = db_screenshot_handler.get_screenshot(id=new_screenshot.id)
assert new_screenshot is not None
assert new_screenshot.file_name == "test_screenshot_2.png"
db_screenshot_handler.delete_screenshot(new_screenshot.id)
db_screenshot_handler.delete_screenshot(id=new_screenshot.id)
rom = db_rom_handler.get_rom(screenshot.rom_id)
rom = db_rom_handler.get_rom(id=screenshot.rom_id)
assert rom is not None
assert len(rom.screenshots) == 1

View File

@@ -57,7 +57,7 @@ class Save(RomAsset):
return None
for screenshot in db_rom.screenshots:
if screenshot.file_name_no_ext == self.file_name:
if screenshot.file_name_no_ext == self.file_name_no_ext:
return screenshot
return None
@@ -81,7 +81,7 @@ class State(RomAsset):
return None
for screenshot in db_rom.screenshots:
if screenshot.file_name_no_ext == self.file_name:
if screenshot.file_name_no_ext == self.file_name_no_ext:
return screenshot
return None

View File

@@ -53,7 +53,7 @@ class Platform(BaseModel):
return self.name
@cached_property
def filesystem_size_bytes(self) -> int:
def fs_size_bytes(self) -> int:
from handler.database import db_stats_handler
return db_stats_handler.get_platform_filesize(self.id)

View File

@@ -200,12 +200,10 @@ class Rom(BaseModel):
@cached_property
def merged_screenshots(self) -> list[str]:
screenshots = [s.download_path for s in self.screenshots]
if self.path_screenshots:
screenshots += [
f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots
]
return screenshots
return [f"{FRONTEND_RESOURCES_PATH}/{s}" for s in self.path_screenshots]
return []
@cached_property
def multi(self) -> bool:

View File

@@ -6,9 +6,6 @@
export type { AddFirmwareResponse } from './models/AddFirmwareResponse';
export type { Body_add_collection_api_collections_post } from './models/Body_add_collection_api_collections_post';
export type { Body_add_firmware_api_firmware_post } from './models/Body_add_firmware_api_firmware_post';
export type { Body_add_saves_api_saves_post } from './models/Body_add_saves_api_saves_post';
export type { Body_add_screenshots_api_screenshots_post } from './models/Body_add_screenshots_api_screenshots_post';
export type { Body_add_states_api_states_post } from './models/Body_add_states_api_states_post';
export type { Body_token_api_token_post } from './models/Body_token_api_token_post';
export type { Body_update_collection_api_collections__id__put } from './models/Body_update_collection_api_collections__id__put';
export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put';
@@ -53,9 +50,6 @@ export type { TinfoilFeedFileSchema } from './models/TinfoilFeedFileSchema';
export type { TinfoilFeedSchema } from './models/TinfoilFeedSchema';
export type { TinfoilFeedTitleDBSchema } from './models/TinfoilFeedTitleDBSchema';
export type { TokenResponse } from './models/TokenResponse';
export type { UploadedSavesResponse } from './models/UploadedSavesResponse';
export type { UploadedScreenshotsResponse } from './models/UploadedScreenshotsResponse';
export type { UploadedStatesResponse } from './models/UploadedStatesResponse';
export type { UserNotesSchema } from './models/UserNotesSchema';
export type { UserSchema } from './models/UserSchema';
export type { ValidationError } from './models/ValidationError';

View File

@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_add_saves_api_saves_post = {
saves: Array<Blob>;
};

View File

@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_add_screenshots_api_screenshots_post = {
screenshots: Array<Blob>;
};

View File

@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_add_states_api_states_post = {
states: Array<Blob>;
};

View File

@@ -25,7 +25,7 @@ export type PlatformSchema = {
aspect_ratio?: string;
created_at: string;
updated_at: string;
filesystem_size_bytes: number;
fs_size_bytes: number;
readonly display_name: string;
};

View File

@@ -1,10 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { SaveSchema } from './SaveSchema';
export type UploadedSavesResponse = {
uploaded: number;
saves: Array<SaveSchema>;
};

View File

@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ScreenshotSchema } from './ScreenshotSchema';
export type UploadedScreenshotsResponse = {
uploaded: number;
screenshots: Array<ScreenshotSchema>;
merged_screenshots: Array<string>;
};

View File

@@ -1,10 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { StateSchema } from './StateSchema';
export type UploadedStatesResponse = {
uploaded: number;
states: Array<StateSchema>;
};

View File

@@ -1,52 +1,23 @@
<script setup lang="ts">
import type { SaveSchema } from "@/__generated__";
import DeleteAssetDialog from "@/components/common/Game/Dialog/Asset/DeleteAssets.vue";
import UploadSavesDialog from "@/components/common/Game/Dialog/Asset/UploadSaves.vue";
import { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp } from "@/utils";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import storeAuth from "@/stores/auth";
import { storeToRefs } from "pinia";
import { getEmptyCoverImage } from "@/utils/covers";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const { mdAndUp } = useDisplay();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const props = defineProps<{ rom: DetailedRom }>();
const selectedSaves = ref<SaveSchema[]>([]);
const lastSelectedIndex = ref<number>(-1);
const emitter = inject<Emitter<Events>>("emitter");
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "file_name",
},
{
title: "Core",
align: "start",
sortable: true,
key: "emulator",
},
{
title: "Updated",
align: "start",
sortable: true,
key: "updated_at",
},
{
title: "Size",
align: "start",
sortable: true,
key: "file_size_bytes",
},
{ title: "", align: "end", key: "actions", sortable: false },
] as const;
// Functions
async function downloasSaves() {
@@ -59,102 +30,158 @@ async function downloasSaves() {
selectedSaves.value = [];
}
function onCardClick(save: SaveSchema, event: MouseEvent) {
const saveIndex = props.rom.user_saves.indexOf(save);
if (event.shiftKey && lastSelectedIndex.value !== null) {
const [startIndex, endIndex] = [lastSelectedIndex.value, saveIndex].sort(
(a, b) => a - b,
);
const rangeSaves = props.rom.user_saves.slice(startIndex, endIndex + 1);
const isDeselecting = selectedSaves.value.includes(save);
if (isDeselecting) {
selectedSaves.value = selectedSaves.value.filter(
(s) => !rangeSaves.includes(s),
);
} else {
const savesToAdd = rangeSaves.filter(
(s) => !selectedSaves.value.includes(s),
);
selectedSaves.value = [...selectedSaves.value, ...savesToAdd];
}
} else {
const isSelected = selectedSaves.value.includes(save);
if (isSelected) {
selectedSaves.value = selectedSaves.value.filter((s) => s.id !== save.id);
} else {
selectedSaves.value = [...selectedSaves.value, save];
}
}
lastSelectedIndex.value = saveIndex;
}
</script>
<template>
<v-data-table-virtual
:items="rom.user_saves"
:width="mdAndUp ? '60vw' : '95vw'"
:headers="HEADERS"
return-object
class="rounded"
v-model="selectedSaves"
show-select
>
<template #header.actions>
<v-btn-group divided density="compact">
<v-btn
v-if="scopes.includes('assets.write')"
size="small"
@click="emitter?.emit('addSavesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
<v-btn
:disabled="!selectedSaves.length"
:variant="selectedSaves.length > 0 ? 'flat' : 'plain'"
size="small"
@click="downloasSaves"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
:class="{
'text-romm-red': selectedSaves.length,
}"
:disabled="!selectedSaves.length || !scopes.includes('assets.write')"
:variant="selectedSaves.length > 0 ? 'flat' : 'plain'"
@click="
emitter?.emit('showDeleteSavesDialog', {
rom: props.rom,
saves: selectedSaves,
})
"
size="small"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</template>
<template #item.file_name="{ item }">
<td class="name-row">
<span>{{ item.file_name }}</span>
</td>
</template>
<template #item.emulator="{ item }">
<v-chip size="x-small" color="orange" label>{{ item.emulator }} </v-chip>
</template>
<template #item.updated_at="{ item }">
<v-chip size="x-small" label>
{{ formatTimestamp(item.updated_at) }}
</v-chip>
</template>
<template #item.file_size_bytes="{ item }">
<v-chip size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
</template>
<template #no-data
><span>{{ t("rom.no-saves-found") }}</span></template
<div>
<v-btn-group divided density="default">
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="emitter?.emit('addSavesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
<v-btn
drawer
:disabled="!selectedSaves.length"
:variant="selectedSaves.length > 0 ? 'flat' : 'plain'"
size="small"
@click="downloasSaves"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
:class="{
'text-romm-red': selectedSaves.length,
}"
:disabled="!selectedSaves.length"
:variant="selectedSaves.length > 0 ? 'flat' : 'plain'"
@click="
emitter?.emit('showDeleteSavesDialog', {
rom: props.rom,
saves: selectedSaves,
})
"
size="small"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</div>
<div class="d-flex ga-4 flex-md-wrap mt-6 px-2">
<v-hover
v-if="rom.user_saves.length > 0"
v-for="save in rom.user_saves"
v-slot="{ isHovering, props }"
>
<template #item.actions="{ item }">
<v-btn-group divided density="compact">
<v-btn :href="item.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: [item],
})
"
<v-card
v-bind="props"
class="bg-toplayer transform-scale"
:class="{
'on-hover': isHovering,
'border-selected': selectedSaves.some((s) => s.id === save.id),
}"
:elevation="isHovering ? 20 : 3"
width="250px"
@click="(e) => onCardClick(save, e)"
>
<v-card-text
class="d-flex flex-column justify-end h-100"
style="padding: 1.5rem"
>
<v-icon class="text-romm-red">mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</template>
</v-data-table-virtual>
<upload-saves-dialog />
<delete-asset-dialog />
<v-row class="position-relative">
<v-img
cover
height="100%"
:src="
save.screenshot?.download_path ??
getEmptyCoverImage(save.file_name)
"
/>
<v-btn-group
v-if="isHovering"
class="position-absolute bottom-0 right-0"
density="compact"
>
<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-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>
{{ formatTimestamp(save.updated_at) }}
</v-chip>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<div v-else>
<v-col class="text-center mt-6">
<v-icon size="x-large">mdi-help-rhombus-outline</v-icon>
<p class="text-h4 mt-2">{{ t("rom.no-saves-found") }}</p>
</v-col>
</div>
</div>
</template>
<style scoped>
.name-row {
min-width: 350px;
}
</style>

View File

@@ -1,52 +1,23 @@
<script setup lang="ts">
import type { StateSchema } from "@/__generated__";
import DeleteAssetDialog from "@/components/common/Game/Dialog/Asset/DeleteAssets.vue";
import UploadStatesDialog from "@/components/common/Game/Dialog/Asset/UploadStates.vue";
import { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp } from "@/utils";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import storeAuth from "@/stores/auth";
import { storeToRefs } from "pinia";
import { getEmptyCoverImage } from "@/utils/covers";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const { mdAndUp } = useDisplay();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const props = defineProps<{ rom: DetailedRom }>();
const selectedStates = ref<StateSchema[]>([]);
const lastSelectedIndex = ref<number>(-1);
const emitter = inject<Emitter<Events>>("emitter");
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "file_name",
},
{
title: "Core",
align: "start",
sortable: true,
key: "emulator",
},
{
title: "Updated",
align: "start",
sortable: true,
key: "updated_at",
},
{
title: "Size",
align: "start",
sortable: true,
key: "file_size_bytes",
},
{ title: "", align: "end", key: "actions", sortable: false },
] as const;
// Functions
async function downloasStates() {
@@ -59,104 +30,160 @@ async function downloasStates() {
selectedStates.value = [];
}
function onCardClick(state: StateSchema, event: MouseEvent) {
const stateIndex = props.rom.user_states.indexOf(state);
if (event.shiftKey && lastSelectedIndex.value !== null) {
const [startIndex, endIndex] = [lastSelectedIndex.value, stateIndex].sort(
(a, b) => a - b,
);
const rangeStates = props.rom.user_states.slice(startIndex, endIndex + 1);
const isDeselecting = selectedStates.value.includes(state);
if (isDeselecting) {
selectedStates.value = selectedStates.value.filter(
(s) => !rangeStates.includes(s),
);
} else {
const statesToAdd = rangeStates.filter(
(s) => !selectedStates.value.includes(s),
);
selectedStates.value = [...selectedStates.value, ...statesToAdd];
}
} else {
const isSelected = selectedStates.value.includes(state);
if (isSelected) {
selectedStates.value = selectedStates.value.filter(
(s) => s.id !== state.id,
);
} else {
selectedStates.value = [...selectedStates.value, state];
}
}
lastSelectedIndex.value = stateIndex;
}
</script>
<template>
<v-data-table-virtual
:items="rom.user_states"
:width="mdAndUp ? '60vw' : '95vw'"
:headers="HEADERS"
class="rounded"
return-object
v-model="selectedStates"
show-select
>
<template #header.actions>
<v-btn-group divided density="compact">
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="emitter?.emit('addStatesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
<v-btn
drawer
:disabled="!selectedStates.length"
:variant="selectedStates.length > 0 ? 'flat' : 'plain'"
size="small"
@click="downloasStates"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
:class="{
'text-romm-red': selectedStates.length,
}"
:disabled="!selectedStates.length"
:variant="selectedStates.length > 0 ? 'flat' : 'plain'"
@click="
emitter?.emit('showDeleteStatesDialog', {
rom: props.rom,
states: selectedStates,
})
"
size="small"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</template>
<template #item.file_name="{ item }">
<td class="name-row">
<span>{{ item.file_name }}</span>
</td>
</template>
<template #item.emulator="{ item }">
<v-chip size="x-small" color="orange" label>{{ item.emulator }}</v-chip>
</template>
<template #item.updated_at="{ item }">
<v-chip size="x-small" label>
{{ formatTimestamp(item.updated_at) }}
</v-chip>
</template>
<template #item.file_size_bytes="{ item }">
<v-chip size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
</template>
<template #no-data
><span>{{ t("rom.no-states-found") }}</span></template
<div>
<v-btn-group divided density="default">
<v-btn
v-if="scopes.includes('assets.write')"
drawer
size="small"
@click="emitter?.emit('addStatesDialog', rom)"
>
<v-icon>mdi-upload</v-icon>
</v-btn>
<v-btn
drawer
:disabled="!selectedStates.length"
:variant="selectedStates.length > 0 ? 'flat' : 'plain'"
size="small"
@click="downloasStates"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
v-if="scopes.includes('assets.write')"
drawer
:class="{
'text-romm-red': selectedStates.length,
}"
:disabled="!selectedStates.length"
:variant="selectedStates.length > 0 ? 'flat' : 'plain'"
@click="
emitter?.emit('showDeleteStatesDialog', {
rom: props.rom,
states: selectedStates,
})
"
size="small"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</div>
<div class="d-flex ga-4 flex-md-wrap mt-6 px-2">
<v-hover
v-if="rom.user_states.length > 0"
v-for="state in rom.user_states"
v-slot="{ isHovering, props }"
>
<template #item.actions="{ item }">
<v-btn-group divided density="compact">
<v-btn drawer :href="item.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: [item],
})
"
<v-card
v-bind="props"
class="bg-toplayer transform-scale"
:class="{
'on-hover': isHovering,
'border-selected': selectedStates.some((s) => s.id === state.id),
}"
:elevation="isHovering ? 20 : 3"
width="250px"
@click="(e) => onCardClick(state, e)"
>
<v-card-text
class="d-flex flex-column justify-end h-100"
style="padding: 1.5rem"
>
<v-icon class="text-romm-red">mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</template>
</v-data-table-virtual>
<upload-states-dialog />
<delete-asset-dialog />
<v-row class="position-relative">
<v-img
cover
height="100%"
:src="
state.screenshot?.download_path ??
getEmptyCoverImage(state.file_name)
"
/>
<v-btn-group
v-if="isHovering"
class="position-absolute bottom-0 right-0"
density="compact"
>
<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-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>
{{ formatTimestamp(state.updated_at) }}
</v-chip>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<div v-else>
<v-col class="text-center mt-6">
<v-icon size="x-large">mdi-help-rhombus-outline</v-icon>
<p class="text-h4 mt-2">{{ t("rom.no-states-found") }}</p>
</v-col>
</div>
</div>
</template>
<style scoped>
.name-row {
min-width: 300px;
}
</style>

View File

@@ -69,7 +69,7 @@ const PLATFORM_INFO_FIELDS: {
{ key: "generation", label: t("platform.generation"), format: identity },
{ key: "family_name", label: t("platform.family"), format: identity },
{
key: "filesystem_size_bytes",
key: "fs_size_bytes",
label: t("common.size-on-disk"),
format: (fs: number) => formatBytes(fs, 2),
},

View File

@@ -1,218 +0,0 @@
<script setup lang="ts">
import type { SaveSchema, StateSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import saveApi from "@/services/api/save";
import stateApi from "@/services/api/state";
import storeRoms, { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes } from "@/utils";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
// Props
const { mdAndUp, smAndUp } = useDisplay();
const romsStore = storeRoms();
const show = ref(false);
const assetType = ref<"user_saves" | "user_states">("user_saves");
const romRef = ref<DetailedRom | null>(null);
const assets = ref<(SaveSchema | StateSchema)[]>([]);
const assetsToDeleteFromFs = ref<number[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showDeleteSavesDialog", ({ rom, saves }) => {
assetType.value = "user_saves";
assets.value = saves;
romRef.value = rom;
show.value = true;
});
emitter?.on("showDeleteStatesDialog", ({ rom, states }) => {
assetType.value = "user_states";
assets.value = states;
romRef.value = rom;
show.value = true;
});
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "file_name",
},
] as const;
const assetsNameMapping = { user_saves: "saves", user_states: "states" };
// Funtcions
// TODO: make remove assets reactive
async function deleteAssets() {
if (!assets.value) return;
const result =
assetType.value === "user_saves"
? saveApi.deleteSaves({
saves: assets.value,
deleteFromFs: assetsToDeleteFromFs.value,
})
: stateApi.deleteStates({
states: assets.value,
deleteFromFs: assetsToDeleteFromFs.value,
});
result
.then(() => {
if (romRef.value?.[assetType.value]) {
const deletedAssetIds = assets.value.map((asset) => asset.id);
romRef.value[assetType.value] =
romRef.value[assetType.value]?.filter(
(asset) => !deletedAssetIds.includes(asset.id),
) ?? [];
romsStore.update(romRef.value);
emitter?.emit("romUpdated", romRef.value);
}
})
.catch(({ response, message }) => {
emitter?.emit("snackbarShow", {
msg: `Unable to delete ${assetsNameMapping[assetType.value]}: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
})
.finally(() => {
closeDialog();
});
}
function closeDialog() {
assetsToDeleteFromFs.value = [];
assets.value = [];
show.value = false;
}
</script>
<template>
<r-dialog
@close="closeDialog"
v-model="show"
icon="mdi-delete"
scroll-content
:width="mdAndUp ? '50vw' : '95vw'"
>
<template #header>
<v-row no-gutters class="justify-center">
<span>Removing</span>
<span class="text-primary mx-1">{{ assets.length }}</span>
<span>{{ assetType.slice(5) }} of</span>
<span class="text-primary mx-1">{{ romRef?.name }}</span>
<span>from RomM</span>
</v-row>
</template>
<template #prepend>
<v-list-item class="text-caption text-center">
<span
>Select the {{ assetType.slice(5) }} you want to remove from your
filesystem, otherwise they will only be deleted from RomM
database.</span
>
</v-list-item>
</template>
<template #content>
<v-data-table-virtual
:item-value="(item) => item.id"
:items="assets"
:width="mdAndUp ? '60vw' : '95vw'"
:headers="HEADERS"
v-model="assetsToDeleteFromFs"
show-select
>
<template #item.file_name="{ item }">
<v-list-item class="px-0">
<v-row no-gutters>
<v-col>
{{ item.file_name
}}<v-chip
v-if="assetsToDeleteFromFs.includes(item.id) && smAndUp"
label
size="x-small"
class="text-romm-red ml-1"
>
Removing from filesystem
</v-chip>
</v-col>
</v-row>
<v-row no-gutters>
<v-col>
<template v-if="!smAndUp">
<v-chip size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
<v-chip
v-if="item.emulator"
size="x-small"
class="ml-1 text-orange"
label
>{{ item.emulator }}
</v-chip>
</template>
<v-chip
v-if="assetsToDeleteFromFs.includes(item.id) && !smAndUp"
label
size="x-small"
class="text-romm-red"
>
Removing from filesystem
</v-chip>
</v-col>
</v-row>
<template #append>
<template v-if="smAndUp">
<v-chip
v-if="item.emulator"
size="x-small"
class="text-orange"
label
>{{ item.emulator }}
</v-chip>
<v-chip class="ml-1" size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
</template>
</template>
</v-list-item>
</template>
</v-data-table-virtual>
</template>
<template #append>
<v-row v-if="assetsToDeleteFromFs.length > 0" no-gutters>
<v-col>
<v-list-item class="text-center mt-2">
<span class="text-romm-red text-body-1">WARNING:</span>
<span class="text-body-2 ml-1">You are going to remove</span>
<span class="text-romm-red text-body-1 ml-1">{{
assetsToDeleteFromFs.length
}}</span>
<span class="text-body-2 ml-1"
>{{ assetType.slice(5) }} from your filesystem. This action can't
be reverted!</span
>
</v-list-item>
</v-col>
</v-row>
<v-row class="justify-center my-2">
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog" variant="flat">
Cancel
</v-btn>
<v-btn
class="text-romm-red bg-toplayer"
variant="flat"
@click="deleteAssets"
>
Confirm
</v-btn>
</v-btn-group>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { SaveSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import saveApi from "@/services/api/save";
import storeRoms, { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes } from "@/utils";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
// Props
const { mdAndUp, smAndUp } = useDisplay();
const romsStore = storeRoms();
const show = ref(false);
const romRef = ref<DetailedRom | null>(null);
const savesRef = ref<SaveSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showDeleteSavesDialog", ({ rom, saves }) => {
savesRef.value = saves;
romRef.value = rom;
show.value = true;
});
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "file_name",
},
] as const;
// Functions
async function deleteSaves() {
if (!savesRef.value) return;
try {
const { data } = await saveApi.deleteSaves({
saves: savesRef.value,
});
if (romRef.value?.user_saves) {
const deletedAssetIds = savesRef.value.map((asset) => asset.id);
romRef.value.user_saves =
romRef.value.user_saves?.filter(
(asset) => !deletedAssetIds.includes(asset.id),
) ?? [];
romsStore.update(romRef.value);
}
emitter?.emit("snackbarShow", {
msg: `Successfully deleted ${data.length} saves`,
icon: "mdi-check-circle",
color: "green",
timeout: 4000,
});
closeDialog();
} catch (error) {
emitter?.emit("snackbarShow", {
msg: `Unable to delete saves: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
}
}
function closeDialog() {
savesRef.value = [];
show.value = false;
}
</script>
<template>
<r-dialog
@close="closeDialog"
v-model="show"
scroll-content
:width="mdAndUp ? '50vw' : '95vw'"
>
<template #header>
<v-row no-gutters class="justify-center">
Deleting {{ savesRef.length }} saves of {{ romRef?.name }} from RomM
</v-row>
</template>
<template #content>
<v-data-table-virtual
:item-value="(item) => item.id"
:items="savesRef"
:width="mdAndUp ? '60vw' : '95vw'"
:headers="HEADERS"
>
<template #item.file_name="{ item }">
<v-list-item class="px-0">
<v-row no-gutters>
<v-col>
<template v-if="!smAndUp">
<v-chip size="x-small" label>
{{ formatBytes(item.file_size_bytes) }}
</v-chip>
<v-chip
v-if="item.emulator"
size="x-small"
class="ml-1 text-orange"
label
>
{{ item.emulator }}
</v-chip>
</template>
{{ item.file_name }}
<v-chip label size="x-small" class="text-romm-red ml-2">
Removing from filesystem
</v-chip>
</v-col>
</v-row>
<template #append>
<template v-if="smAndUp">
<v-chip
v-if="item.emulator"
size="x-small"
class="text-orange"
label
>{{ item.emulator }}
</v-chip>
<v-chip class="ml-1" size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
</template>
</template>
</v-list-item>
</template>
</v-data-table-virtual>
</template>
<template #append>
<v-row no-gutters>
<v-col>
<v-list-item class="text-center mt-2">
<span class="text-romm-red text-body-1">
WARNING: These save will be removed from the filesystem. This
action is irreversible!
</span>
</v-list-item>
</v-col>
</v-row>
<v-row class="justify-center my-2">
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog" variant="flat">
Cancel
</v-btn>
<v-btn
class="text-romm-red bg-toplayer"
variant="flat"
@click="deleteSaves"
>
Confirm
</v-btn>
</v-btn-group>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { StateSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import stateApi from "@/services/api/state";
import storeRoms, { type DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { formatBytes } from "@/utils";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
// Props
const { mdAndUp, smAndUp } = useDisplay();
const romsStore = storeRoms();
const show = ref(false);
const romRef = ref<DetailedRom | null>(null);
const statesRef = ref<StateSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showDeleteStatesDialog", ({ rom, states }) => {
statesRef.value = states;
romRef.value = rom;
show.value = true;
});
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "file_name",
},
] as const;
// Functions
async function deleteStates() {
if (!statesRef.value) return;
try {
const { data } = await stateApi.deleteStates({
states: statesRef.value,
});
if (romRef.value?.user_states) {
const deletedAssetIds = statesRef.value.map((asset) => asset.id);
romRef.value.user_states =
romRef.value.user_states?.filter(
(asset) => !deletedAssetIds.includes(asset.id),
) ?? [];
romsStore.update(romRef.value);
}
emitter?.emit("snackbarShow", {
msg: `Successfully deleted ${data.length} states`,
icon: "mdi-check-circle",
color: "green",
timeout: 4000,
});
closeDialog();
} catch (error) {
emitter?.emit("snackbarShow", {
msg: `Unable to delete states: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
}
}
function closeDialog() {
statesRef.value = [];
show.value = false;
}
</script>
<template>
<r-dialog
@close="closeDialog"
v-model="show"
scroll-content
:width="mdAndUp ? '50vw' : '95vw'"
>
<template #header>
<v-row no-gutters class="justify-center">
Deleting {{ statesRef.length }} states of {{ romRef?.name }} from RomM
</v-row>
</template>
<template #content>
<v-data-table-virtual
:item-value="(item) => item.id"
:items="statesRef"
:width="mdAndUp ? '60vw' : '95vw'"
:headers="HEADERS"
>
<template #item.file_name="{ item }">
<v-list-item class="px-0">
<v-row no-gutters>
<v-col>
<template v-if="!smAndUp">
<v-chip size="x-small" label>
{{ formatBytes(item.file_size_bytes) }}
</v-chip>
<v-chip
v-if="item.emulator"
size="x-small"
class="ml-1 text-orange"
label
>
{{ item.emulator }}
</v-chip>
</template>
{{ item.file_name }}
<v-chip label size="x-small" class="text-romm-red ml-2">
Removing from filesystem
</v-chip>
</v-col>
</v-row>
<template #append>
<template v-if="smAndUp">
<v-chip
v-if="item.emulator"
size="x-small"
class="text-orange"
label
>{{ item.emulator }}
</v-chip>
<v-chip class="ml-1" size="x-small" label
>{{ formatBytes(item.file_size_bytes) }}
</v-chip>
</template>
</template>
</v-list-item>
</template>
</v-data-table-virtual>
</template>
<template #append>
<v-row no-gutters>
<v-col>
<v-list-item class="text-center mt-2">
<span class="text-romm-red text-body-1">
WARNING: These state will be removed from the filesystem. This
action is irreversible!
</span>
</v-list-item>
</v-col>
</v-row>
<v-row class="justify-center my-2">
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog" variant="flat">
Cancel
</v-btn>
<v-btn
class="text-romm-red bg-toplayer"
variant="flat"
@click="deleteStates"
>
Confirm
</v-btn>
</v-btn-group>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import type { SaveSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import type { DetailedRom } from "@/stores/roms";
import storeAuth from "@/stores/auth";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp } from "@/utils";
import { getEmptyCoverImage } from "@/utils/covers";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const { mdAndUp } = useDisplay();
const show = ref(false);
const rom = ref<DetailedRom | null>(null);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("selectSaveDialog", (selectedRom) => {
rom.value = selectedRom;
show.value = true;
});
function onCardClick(save: SaveSchema) {
if (!save) return;
emitter?.emit("saveSelected", save);
closeDialog();
}
function closeDialog() {
show.value = false;
rom.value = null;
window.EJS_emulator?.play();
}
</script>
<template>
<r-dialog
@close="closeDialog"
v-model="show"
icon="mdi-format-wrap-square"
scroll-content
:width="mdAndUp ? '50vw' : '95vw'"
id="select-save-dialog"
>
<template #header>
<span class="text-h5 ml-4">{{ t("play.select-save") }}</span>
</template>
<template #content>
<div v-if="rom" class="d-flex justify-center ga-4 flex-md-wrap py-6 px-2">
<v-hover
v-if="rom.user_saves.length > 0"
v-for="save in rom.user_saves"
v-slot="{ isHovering, props }"
>
<v-card
v-bind="props"
class="bg-toplayer transform-scale"
:class="{
'on-hover': isHovering,
}"
:elevation="isHovering ? 20 : 3"
width="200px"
@click="onCardClick(save)"
>
<v-card-text
class="d-flex flex-column justify-end h-100"
style="padding: 1.5rem"
>
<v-row>
<v-img
cover
height="100%"
:src="
save.screenshot?.download_path ??
getEmptyCoverImage(save.file_name)
"
/>
</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>
{{ formatTimestamp(save.updated_at) }}
</v-chip>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<div v-else>
<v-col class="text-center mt-6">
<v-icon size="x-large">mdi-help-rhombus-outline</v-icon>
<p class="text-h4 mt-2">{{ t("rom.no-states-found") }}</p>
</v-col>
</div>
</div>
</template>
<template #append>
<v-row class="justify-center my-2">
<v-btn class="bg-toplayer" variant="flat" @click="closeDialog">
Cancel
</v-btn>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import type { StateSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import type { DetailedRom } from "@/stores/roms";
import storeAuth from "@/stores/auth";
import type { Events } from "@/types/emitter";
import { formatBytes, formatTimestamp } from "@/utils";
import { getEmptyCoverImage } from "@/utils/covers";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useDisplay } from "vuetify";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const auth = storeAuth();
const { scopes } = storeToRefs(auth);
const { mdAndUp } = useDisplay();
const show = ref(false);
const rom = ref<DetailedRom | null>(null);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("selectStateDialog", (selectedRom) => {
rom.value = selectedRom;
show.value = true;
});
function onCardClick(state: StateSchema) {
if (!state) return;
emitter?.emit("stateSelected", state);
closeDialog();
}
function closeDialog() {
show.value = false;
rom.value = null;
window.EJS_emulator?.play();
}
</script>
<template>
<r-dialog
@close="closeDialog"
v-model="show"
icon="mdi-format-wrap-square"
scroll-content
:width="mdAndUp ? '50vw' : '95vw'"
id="select-state-dialog"
>
<template #header>
<span class="text-h5 ml-4">{{ t("play.select-state") }}</span>
</template>
<template #content>
<div v-if="rom" class="d-flex justify-center ga-4 flex-md-wrap py-6 px-2">
<v-hover
v-if="rom.user_states.length > 0"
v-for="state in rom.user_states"
v-slot="{ isHovering, props }"
>
<v-card
v-bind="props"
class="bg-toplayer transform-scale"
:class="{
'on-hover': isHovering,
}"
:elevation="isHovering ? 20 : 3"
width="200px"
@click="onCardClick(state)"
>
<v-card-text
class="d-flex flex-column justify-end h-100"
style="padding: 1.5rem"
>
<v-row>
<v-img
cover
height="100%"
:src="
state.screenshot?.download_path ??
getEmptyCoverImage(state.file_name)
"
/>
</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>
{{ formatTimestamp(state.updated_at) }}
</v-chip>
</v-row>
</v-card-text>
</v-card>
</v-hover>
<div v-else>
<v-col class="text-center mt-6">
<v-icon size="x-large">mdi-help-rhombus-outline</v-icon>
<p class="text-h4 mt-2">{{ t("rom.no-states-found") }}</p>
</v-col>
</div>
</div>
</template>
<template #append>
<v-row class="justify-center my-2">
<v-btn class="bg-toplayer" variant="flat" @click="closeDialog">
Cancel
</v-btn>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -54,13 +54,15 @@ async function uploadSaves() {
saveApi
.uploadSaves({
rom: rom.value,
saves: filesToUpload.value,
savesToUpload: filesToUpload.value.map((saveFile) => ({
saveFile,
})),
})
.then(({ data }) => {
const { saves, uploaded } = data;
.then((data) => {
const saves = data;
emitter?.emit("snackbarShow", {
msg: `Uploaded ${uploaded} files successfully!`,
msg: `Uploaded ${saves.length} files successfully!`,
icon: "mdi-check-bold",
color: "green",
timeout: 2000,

View File

@@ -54,17 +54,29 @@ function uploadStates() {
stateApi
.uploadStates({
rom: rom.value,
states: filesToUpload.value,
statesToUpload: filesToUpload.value.map((stateFile) => ({
stateFile,
})),
})
.then(({ data }) => {
const { states, uploaded } = data;
.then((data) => {
const saves = data;
emitter?.emit("snackbarShow", {
msg: `${uploaded} files uploaded successfully.`,
msg: `Uploaded ${saves.length} files successfully!`,
icon: "mdi-check-bold",
color: "green",
timeout: 2000,
});
})
.catch(({ response, message }) => {
emitter?.emit("snackbarShow", {
msg: `Unable to upload saves: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
});
closeDialog();

View File

@@ -33,14 +33,16 @@ function closeDialog() {
@timeout="closeDialog"
absolute
:location="xs ? 'top' : 'top right'"
color="tooltip"
color="primary-darken"
>
<v-icon
:icon="snackbarStatus.icon"
:color="snackbarStatus.color"
class="mx-2"
/>
{{ snackbarStatus.msg }}
<template #text>
<v-row class="d-flex align-center px-2">
<v-icon :icon="snackbarStatus.icon" class="mx-2" />
<span class="text-subtitle-1 font-weight-bold">
{{ snackbarStatus.msg }}
</span>
</v-row>
</template>
<template #actions>
<v-btn variant="text" @click="closeDialog">
<v-icon icon="mdi-close" />

View File

@@ -11,6 +11,12 @@ import Notification from "@/components/common/Notifications/Notification.vue";
import UploadProgress from "@/components/common/Notifications/UploadProgress.vue";
import SearchCoverDialog from "@/components/common/SearchCover.vue";
import ShowQRCodeDialog from "@/components/common/Game/Dialog/ShowQRCode.vue";
import UploadSavesDialog from "@/components/common/Game/Dialog/Asset/UploadSaves.vue";
import UploadStatesDialog from "@/components/common/Game/Dialog/Asset/UploadStates.vue";
import SelectSaveDialog from "@/components/common/Game/Dialog/Asset/SelectSave.vue";
import SelectStateDialog from "@/components/common/Game/Dialog/Asset/SelectState.vue";
import DeleteSavesDialog from "@/components/common/Game/Dialog/Asset/DeleteSaves.vue";
import DeleteStatesDialog from "@/components/common/Game/Dialog/Asset/DeleteStates.vue";
import collectionApi from "@/services/api/collection";
import platformApi from "@/services/api/platform";
import storeCollections from "@/stores/collections";
@@ -99,4 +105,11 @@ onBeforeMount(async () => {
<new-version-dialog />
<upload-progress />
<upload-saves-dialog />
<delete-saves-dialog />
<upload-states-dialog />
<delete-states-dialog />
<select-save-dialog />
<select-state-dialog />
</template>

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Vollbild",
"play": "Spielen",
"reset-session": "Session zurücksetzen",
"quit": "Beenden",
"save-and-quit": "Speichern und beenden",
"back-to-game-details": "Zurück zu den Spieldetails",
"back-to-gallery": "Zurück zur Plattformübersicht",
"clear-cache": "EmulatorJS-Cache löschen",
"clear-cache-title": "Möchtest du den EmulatorJS-Cache wirklich löschen?",
"clear-cache-warning": "Dadurch werden alle im Browser gespeicherten Spielstände und Speicherungen entfernt.",
"clear-cache-description": "Es hat keine Auswirkungen auf Spielstände und Speicherungen, die auf dem Server gespeichert sind."
"clear-cache-description": "Es hat keine Auswirkungen auf Spielstände und Speicherungen, die auf dem Server gespeichert sind.",
"select-save": "Speicherstand auswählen",
"select-state": "Speicherstand auswählen"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Full Screen",
"full-screen": "Full screen",
"play": "Play",
"reset-session": "Reset Session",
"quit": "Quit",
"save-and-quit": "Save and quit",
"back-to-game-details": "Back to game details",
"back-to-gallery": "Back to gallery",
"clear-cache": "Clear EmulatorJS Cache",
"clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?",
"clear-cache-warning": "This will remove all saves and states stored in the browser.",
"clear-cache-description": "Any saves or states stored on the server will not be affected."
"clear-cache-description": "Any saves or states stored on the server will not be affected.",
"select-save": "Select save",
"select-state": "Select state"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Full Screen",
"full-screen": "Full screen",
"play": "Play",
"reset-session": "Reset Session",
"quit": "Quit",
"save-and-quit": "Save and quit",
"back-to-game-details": "Back to game details",
"back-to-gallery": "Back to gallery",
"clear-cache": "Clear EmulatorJS Cache",
"clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?",
"clear-cache-warning": "This will remove all saves and states stored in the browser.",
"clear-cache-description": "Any saves or states stored on the server will not be affected."
"clear-cache-description": "Any saves or states stored on the server will not be affected.",
"select-save": "Select save",
"select-state": "Select state"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Pantalla completa",
"play": "Jugar",
"reset-session": "Restablecer sesión",
"quit": "Salir",
"save-and-quit": "Guardar y salir",
"back-to-game-details": "Volver a detalles",
"back-to-gallery": "Volver a galería",
"clear-cache": "Limpiar caché de EmulatorJS",
"clear-cache-title": "¿Estás seguro de que quieres limpiar la caché de EmulatorJS?",
"clear-cache-warning": "Esto eliminará todas las partidas y estados almacenados en el navegador.",
"clear-cache-description": "No afectará a las partidas o estados almacenados en el servidor."
"clear-cache-description": "No afectará a las partidas guardadas o estados almacenados en el servidor.",
"select-save": "Seleccionar guardado",
"select-state": "Seleccionar estado"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Plein écran",
"play": "Jouer",
"reset-session": "Réinitialiser la session",
"quit": "Quitter",
"save-and-quit": "Sauvegarder et quitter",
"back-to-game-details": "Retour aux détails du jeu",
"back-to-gallery": "Retour à la galerie",
"clear-cache": "Effacer le cache EmulatorJS",
"clear-cache-title": "Êtes-vous sûr de vouloir effacer le cache EmulatorJS ?",
"clear-cache-warning": "Cela supprimera toutes les sauvegardes et les états stockés dans le navigateur.",
"clear-cache-description": "Les sauvegardes ou les états stockés sur le serveur ne seront pas affectés."
"clear-cache-description": "Les sauvegardes ou les états stockés sur le serveur ne seront pas affectés.",
"select-save": "Sélectionner la sauvegarde",
"select-state": "Sélectionner l'état"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "全画面",
"play": "プレイ",
"reset-session": "セッションをリセット",
"quit": "終了",
"save-and-quit": "保存して終了",
"back-to-game-details": "ゲーム詳細へ戻る",
"back-to-gallery": "ギャラリーへ戻る",
"clear-cache": "EmulatorJS キャッシュをクリア",
"clear-cache-title": "EmulatorJS キャッシュをクリアしてもよろしいですか?",
"clear-cache-warning": "これにより、ブラウザに保存されているすべてのセーブデータとステートが削除されます。",
"clear-cache-description": "サーバーに保存されているセーブデータやステートには影響しません。"
"clear-cache-description": "サーバーに保存されているセーブデータやステートには影響しません。",
"select-save": "セーブデータを選択",
"select-state": "ステートを選択"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "전체 화면",
"play": "실행",
"reset-session": "세션 초기화",
"quit": "종료",
"save-and-quit": "저장하고 종료",
"back-to-game-details": "게임 설명으로 가기",
"back-to-gallery": "갤러리로 가기",
"clear-cache": "EmulatorJS 캐시 지우기",
"clear-cache-title": "EmulatorJS 캐시를 지우시겠습니까?",
"clear-cache-warning": "이로 인해 브라우저에 저장된 모든 세이브 및 상태가 제거됩니다.",
"clear-cache-description": "서버에 저장된 세이브 및 상태에는 영향을 주지 않습니다."
"clear-cache-description": "서버에 저장된 세이브 및 상태에는 영향을 주지 않습니다.",
"select-save": "세이브 선택",
"select-state": "상태 선택"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Tela cheia",
"play": "Jogar",
"reset-session": "Redefinir sessão",
"quit": "Sair",
"save-and-quit": "Salvar e sair",
"back-to-game-details": "Voltar aos detalhes do jogo",
"back-to-gallery": "Voltar à galeria",
"clear-cache": "Limpar cache do EmulatorJS",
"clear-cache-title": "Tem certeza de que deseja limpar o cache do EmulatorJS?",
"clear-cache-warning": "Isso removerá todos os saves e estados armazenados no navegador.",
"clear-cache-description": "Não afetará nenhum save ou estado armazenado no servidor."
"clear-cache-description": "Não afetará nenhum save ou estado armazenado no servidor.",
"select-save": "Selecionar save",
"select-state": "Selecionar estado"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Ecran complet",
"play": "Joacă",
"reset-session": "Resetează sesiunea",
"quit": "Ieși",
"save-and-quit": "Salvează și ieși",
"back-to-game-details": "Înapoi la detaliile jocului",
"back-to-gallery": "Înapoi la galerie",
"clear-cache": "Șterge cache-ul EmulatorJS",
"clear-cache-title": "Ești sigur că vrei să ștergi cache-ul EmulatorJS?",
"clear-cache-warning": "Acest lucru va elimina toate salvările și stările stocate în browser.",
"clear-cache-description": "Nu va afecta nicio salvare sau stare stocată pe server."
"clear-cache-description": "Nu va afecta nicio salvare sau stare stocată pe server.",
"select-save": "Selectează salvare",
"select-state": "Selectează stare"
}

View File

@@ -1,11 +1,14 @@
{
"full-screen": "Полный экран",
"play": "Играть",
"reset-session": "Сбросить сессию",
"quit": "Выйти",
"save-and-quit": "Сохранить и выйти",
"back-to-game-details": "Вернуться к деталям игры",
"back-to-gallery": "Вернуться в галерею",
"clear-cache": "Очистить кэш EmulatorJS",
"clear-cache-title": "Вы уверены, что хотите очистить кэш EmulatorJS?",
"clear-cache-warning": "Это удалит все сохранения и состояния, хранящиеся в браузере.",
"clear-cache-description": "Любые сохранения или состояния, хранящиеся на сервере, не будут затронуты."
"clear-cache-description": "Любые сохранения или состояния, хранящиеся на сервере, не будут затронуты.",
"select-save": "Выбрать сохранение",
"select-state": "Выбрать состояние"
}

View File

@@ -1,7 +1,8 @@
{
"full-screen": "全屏",
"play": "游玩",
"reset-session": "重置会话",
"quit": "退出",
"save-and-quit": "保存并退出",
"back-to-game-details": "返回游戏详情",
"back-to-gallery": "返回游戏库",
"clear-cache": "清除 EmulatorJS 缓存",

View File

@@ -2,6 +2,7 @@ import type {
MessageResponse,
SearchRomSchema,
RomUserSchema,
RomSchema,
} from "@/__generated__";
import api from "@/services/api/index";
import socket from "@/services/socket";
@@ -19,7 +20,7 @@ async function uploadRoms({
}: {
platformId: number;
filesToUpload: File[];
}): Promise<PromiseSettledResult<unknown>[]> {
}): Promise<PromiseSettledResult<RomSchema>[]> {
const heartbeat = storeHeartbeat();
if (!socket.connected) socket.connect();
@@ -30,7 +31,7 @@ async function uploadRoms({
formData.append(file.name, file);
uploadStore.start(file.name);
return new Promise((resolve, reject) => {
return new Promise<RomSchema>((resolve, reject) => {
api
.post("/roms", formData, {
headers: {
@@ -44,7 +45,9 @@ async function uploadRoms({
uploadStore.update(file.name, progressEvent);
},
})
.then(resolve)
.then(({ data }) => {
resolve(data);
})
.catch((error) => {
uploadStore.fail(file.name, error.response?.data?.detail);
reject(error);

View File

@@ -1,57 +1,71 @@
import type { SaveSchema, UploadedSavesResponse } from "@/__generated__";
import api from "@/services/api/index";
import type { DetailedRom } from "@/stores/roms";
import type { SaveSchema } from "@/__generated__";
export const saveApi = api;
async function uploadSaves({
rom,
saves,
savesToUpload,
emulator,
}: {
rom: DetailedRom;
saves: File[];
savesToUpload: {
saveFile: File;
screenshotFile?: File;
}[];
emulator?: string;
}): Promise<{ data: UploadedSavesResponse }> {
const formData = new FormData();
saves.forEach((save) => formData.append("saves", save));
}): Promise<PromiseSettledResult<SaveSchema>[]> {
const promises = savesToUpload.map(({ saveFile, screenshotFile }) => {
const formData = new FormData();
formData.append("saveFile", saveFile);
if (screenshotFile) {
formData.append("screenshotFile", screenshotFile);
}
return api.post("/saves", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id, emulator },
return new Promise<SaveSchema>((resolve, reject) => {
api
.post("/saves", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id, emulator },
})
.then(({ data }) => {
resolve(data);
})
.catch(reject);
});
});
return Promise.allSettled(promises);
}
async function updateSave({
save,
file,
saveFile,
}: {
save: SaveSchema;
file: File;
saveFile: File;
}): Promise<{ data: SaveSchema }> {
const formData = new FormData();
formData.append("file", file);
formData.append("saveFile", saveFile);
return api.put(`/saves/${save.id}`, formData);
}
async function deleteSaves({
saves,
deleteFromFs,
}: {
saves: SaveSchema[];
deleteFromFs: number[];
}) {
}): Promise<{ data: number[] }> {
return api.post("/saves/delete", {
saves: saves.map((s) => s.id),
delete_from_fs: deleteFromFs,
});
}
export default {
deleteSaves,
updateSave,
uploadSaves,
updateSave,
deleteSaves,
};

View File

@@ -1,41 +1,51 @@
import type {
ScreenshotSchema,
UploadedScreenshotsResponse,
} from "@/__generated__";
import api from "@/services/api/index";
import type { DetailedRom } from "@/stores/roms";
import type { ScreenshotSchema } from "@/__generated__";
export const screenshotApi = api;
async function uploadScreenshots({
rom,
screenshots,
screenshotsToUpload,
emulator,
}: {
rom: DetailedRom;
screenshots: File[];
}): Promise<{ data: UploadedScreenshotsResponse }> {
const formData = new FormData();
screenshots.forEach((screenshot) =>
formData.append("screenshots", screenshot),
);
screenshotsToUpload: {
screenshotFile: File;
}[];
emulator?: string;
}): Promise<PromiseSettledResult<ScreenshotSchema>[]> {
const promises = screenshotsToUpload.map(({ screenshotFile }) => {
const formData = new FormData();
formData.append("screenshotFile", screenshotFile);
return api.post("/screenshots", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id },
return new Promise<ScreenshotSchema>((resolve, reject) => {
api
.post("/screenshots", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id, emulator },
})
.then(({ data }) => {
resolve(data);
})
.catch(reject);
});
});
return Promise.allSettled(promises);
}
async function updateScreenshot({
screenshot,
file,
screenshotFile,
}: {
screenshot: ScreenshotSchema;
file: File;
screenshotFile: File;
}): Promise<{ data: ScreenshotSchema }> {
const formData = new FormData();
formData.append("file", file);
formData.append("screenshotFile", screenshotFile);
return api.put(`/screenshots/${screenshot.id}`, formData);
}

View File

@@ -1,52 +1,66 @@
import type { StateSchema, UploadedStatesResponse } from "@/__generated__";
import api from "@/services/api/index";
import type { DetailedRom } from "@/stores/roms";
import type { StateSchema } from "@/__generated__";
export const stateApi = api;
async function uploadStates({
rom,
states,
statesToUpload,
emulator,
}: {
rom: DetailedRom;
states: File[];
statesToUpload: {
stateFile: File;
screenshotFile?: File;
}[];
emulator?: string;
}): Promise<{ data: UploadedStatesResponse }> {
const formData = new FormData();
states.forEach((state) => formData.append("states", state));
}): Promise<PromiseSettledResult<StateSchema>[]> {
const promises = statesToUpload.map(({ stateFile, screenshotFile }) => {
const formData = new FormData();
formData.append("stateFile", stateFile);
if (screenshotFile) {
formData.append("screenshotFile", screenshotFile);
}
return api.post("/states", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id, emulator },
return new Promise<StateSchema>((resolve, reject) => {
api
.post("/states", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
params: { rom_id: rom.id, emulator },
})
.then(({ data }) => {
resolve(data);
})
.catch(reject);
});
});
return Promise.allSettled(promises);
}
async function updateState({
state,
file,
stateFile,
}: {
state: StateSchema;
file: File;
stateFile: File;
}): Promise<{ data: StateSchema }> {
const formData = new FormData();
formData.append("file", file);
formData.append("stateFile", stateFile);
return api.put(`/states/${state.id}`, formData);
}
async function deleteStates({
states,
deleteFromFs,
}: {
states: StateSchema[];
deleteFromFs: number[];
}) {
}): Promise<{ data: number[] }> {
return api.post("/states/delete", {
states: states.map((s) => s.id),
delete_from_fs: deleteFromFs,
});
}

View File

@@ -1,7 +1,8 @@
const commonColors = {
"romm-red": "#da3633",
"romm-red": "#DA3633",
"romm-green": "#3FB950",
"romm-white": "#fefdfe",
"romm-blue": "#0070F3",
"romm-white": "#FEFDFE",
"romm-gray": "#5D5D5D",
"romm-black": "#000000",
};
@@ -27,7 +28,7 @@ export const light = {
dark: false,
colors: {
primary: "#371f69",
secondary: "#553e98",
secondary: "#553E98",
accent: "#E1A38D",
surface: "#FFFFFF",
background: "#F2F4F8",

View File

@@ -75,6 +75,9 @@ export type Events = {
firmwareDrawerShow: null;
updateDataTablePages: null;
sortBarShow: null;
romUpdated: DetailedRom;
showQRCodeDialog: SimpleRom;
selectSaveDialog: DetailedRom;
selectStateDialog: DetailedRom;
saveSelected: SaveSchema;
stateSelected: StateSchema;
};

View File

@@ -32,7 +32,7 @@ export function getCollectionCoverImage(name: string): string {
const tbgs = translatedBGs(name);
const bgr = bgRotation(name);
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M201.212 336.962h-26.135v182.942c0 14.374 11.76 26.134 26.135 26.134h182.942v-26.134H201.212zm209.076-52.27H253.481c-14.374 0-26.135 11.76-26.135 26.135v156.808c0 14.374 11.76 26.134 26.135 26.134h156.807c14.375 0 26.135-11.76 26.135-26.134V310.827c0-14.374-11.76-26.135-26.135-26.135m0 130.673-32.668-19.6-32.668 19.6V310.827h65.336z" style="fill:#f9f9f9;stroke-width:13.0673"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M201.212 336.962h-26.135v182.942c0 14.374 11.76 26.134 26.135 26.134h182.942v-26.134H201.212zm209.076-52.27H253.481c-14.374 0-26.135 11.76-26.135 26.135v156.808c0 14.374 11.76 26.134 26.135 26.134h156.807c14.375 0 26.135-11.76 26.135-26.134V310.827c0-14.374-11.76-26.135-26.135-26.135m0 130.673-32.668-19.6-32.668 19.6V310.827h65.336z" style="fill:#f9f9f9;stroke-width:13.0673"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
return strToObjUrl(svgString);
}
@@ -41,7 +41,7 @@ export function getFavoriteCoverImage(name: string): string {
const tbgs = translatedBGs(name);
const bgr = bgRotation(name);
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M300 479.05 392.7 535l-24.6-105.45L450 358.6l-107.85-9.3L300 250l-42.15 99.3L150 358.6l81.75 70.95L207.3 535Z" style="fill:#f9f9f9;stroke-width:225"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M300 479.05 392.7 535l-24.6-105.45L450 358.6l-107.85-9.3L300 250l-42.15 99.3L150 358.6l81.75 70.95L207.3 535Z" style="fill:#f9f9f9;stroke-width:225"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
return strToObjUrl(svgString);
}
@@ -51,7 +51,7 @@ export function getMissingCoverImage(name: string): string {
const bgr = bgRotation(name);
const icoR = [90, 0, 270, 180][hashString(name) % 4];
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M204.545 345.455A54.545 54.545 0 0 1 259.091 400a54.545 54.545 0 0 1-54.546 54.545A54.545 54.545 0 0 1 150 400a54.545 54.545 0 0 1 54.545-54.545M300 250a54.545 54.545 0 0 1 54.545 54.545A54.545 54.545 0 0 1 300 359.091a54.545 54.545 0 0 1-54.545-54.546A54.545 54.545 0 0 1 300 250m0 190.91a54.545 54.545 0 0 1 54.545 54.545A54.545 54.545 0 0 1 300 550a54.545 54.545 0 0 1-54.545-54.545A54.545 54.545 0 0 1 300 440.909m95.455-95.454A54.545 54.545 0 0 1 450 400a54.545 54.545 0 0 1-54.545 54.545A54.545 54.545 0 0 1 340.909 400a54.545 54.545 0 0 1 54.546-54.545m-190.91 27.272A27.273 27.273 0 0 0 177.273 400a27.273 27.273 0 0 0 27.272 27.273A27.273 27.273 0 0 0 231.818 400a27.273 27.273 0 0 0-27.273-27.273m190.91 0A27.273 27.273 0 0 0 368.182 400a27.273 27.273 0 0 0 27.273 27.273A27.273 27.273 0 0 0 422.727 400a27.273 27.273 0 0 0-27.272-27.273M300 468.182a27.273 27.273 0 0 0-27.273 27.273A27.273 27.273 0 0 0 300 522.727a27.273 27.273 0 0 0 27.273-27.272A27.273 27.273 0 0 0 300 468.182" style="fill:#f9f9f9;stroke-width:13.6364;transform-origin:center;transform:rotate(${icoR}deg);"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M204.545 345.455A54.545 54.545 0 0 1 259.091 400a54.545 54.545 0 0 1-54.546 54.545A54.545 54.545 0 0 1 150 400a54.545 54.545 0 0 1 54.545-54.545M300 250a54.545 54.545 0 0 1 54.545 54.545A54.545 54.545 0 0 1 300 359.091a54.545 54.545 0 0 1-54.545-54.546A54.545 54.545 0 0 1 300 250m0 190.91a54.545 54.545 0 0 1 54.545 54.545A54.545 54.545 0 0 1 300 550a54.545 54.545 0 0 1-54.545-54.545A54.545 54.545 0 0 1 300 440.909m95.455-95.454A54.545 54.545 0 0 1 450 400a54.545 54.545 0 0 1-54.545 54.545A54.545 54.545 0 0 1 340.909 400a54.545 54.545 0 0 1 54.546-54.545m-190.91 27.272A27.273 27.273 0 0 0 177.273 400a27.273 27.273 0 0 0 27.272 27.273A27.273 27.273 0 0 0 231.818 400a27.273 27.273 0 0 0-27.273-27.273m190.91 0A27.273 27.273 0 0 0 368.182 400a27.273 27.273 0 0 0 27.273 27.273A27.273 27.273 0 0 0 422.727 400a27.273 27.273 0 0 0-27.272-27.273M300 468.182a27.273 27.273 0 0 0-27.273 27.273A27.273 27.273 0 0 0 300 522.727a27.273 27.273 0 0 0 27.273-27.272A27.273 27.273 0 0 0 300 468.182" style="fill:#f9f9f9;stroke-width:13.6364;transform-origin:center;transform:rotate(${icoR}deg);"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
return strToObjUrl(svgString);
}
@@ -60,7 +60,7 @@ export function getUnmatchedCoverImage(name: string): string {
const tbgs = translatedBGs(name);
const bgr = bgRotation(name);
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M300 225c-8.748 0-17.496 3.324-24.669 10.322L135.366 375.287a34.536 34.536 0 0 0 0 49.338L275.331 564.59a34.536 34.536 0 0 0 49.338 0l139.965-139.965a34.536 34.536 0 0 0 0-49.338L324.669 235.322C317.496 228.324 308.748 225 300 225m0 86.603c47.238 1.925 67.708 49.513 39.89 85.03-7.348 8.747-19.07 14.52-25.019 22.044-6.123 7.523-6.123 16.27-6.123 25.018h-26.244c0-14.871 0-27.293 6.124-36.04 5.773-8.748 17.495-13.997 24.844-19.77 21.52-19.77 15.92-47.589-13.472-49.863-14.346 0-26.243 11.722-26.243 26.418h-26.244c0-29.218 23.62-52.837 52.487-52.837m-17.496 149.588h26.244v26.243h-26.244z" style="fill:#f9f9f9;stroke-width:17.4956"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/><path d="M300 225c-8.748 0-17.496 3.324-24.669 10.322L135.366 375.287a34.536 34.536 0 0 0 0 49.338L275.331 564.59a34.536 34.536 0 0 0 49.338 0l139.965-139.965a34.536 34.536 0 0 0 0-49.338L324.669 235.322C317.496 228.324 308.748 225 300 225m0 86.603c47.238 1.925 67.708 49.513 39.89 85.03-7.348 8.747-19.07 14.52-25.019 22.044-6.123 7.523-6.123 16.27-6.123 25.018h-26.244c0-14.871 0-27.293 6.124-36.04 5.773-8.748 17.495-13.997 24.844-19.77 21.52-19.77 15.92-47.589-13.472-49.863-14.346 0-26.243 11.722-26.243 26.418h-26.244c0-29.218 23.62-52.837 52.487-52.837m-17.496 149.588h26.244v26.243h-26.244z" style="fill:#f9f9f9;stroke-width:17.4956"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
return strToObjUrl(svgString);
}
@@ -69,7 +69,7 @@ export function getEmptyCoverImage(name: string): string {
const tbgs = translatedBGs(name);
const bgr = bgRotation(name);
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 600 800"><g fill="none" mask="url(#a)"><path fill="#553E98" d="M0 0h600v800H0z"/><path fill="#371f69" d="M0 580c120 10 180-130 270-190s220-70 290-150c80-90 140-210 120-320S520-250 420-310C340 30 250 0 160-20S-10-50-90-20s-150 70-200 140-60 150-85 230c-30 100-130 200-90 290s190 70 270 130c45-340 85-200 195-190" style="transform-origin:center;transform:translate(${tbgs.left.x}px,${tbgs.left.y}px) rotate(${bgr}deg);"/><path fill="#FF9B85" d="M600 1060c100 30 230 40 310-40s30-210 70-310c35-90 130-150 140-240 10-100-10-220-90-290s-200-40-300-60c-90-20-180-60-270-30S310 200 240 260C170 330 50 380 40 480s110 160 170 240c50 70 90 130 150 180 70 60 140 140 230 160" style="transform-origin:center;transform:translate(${tbgs.right.x}px,${tbgs.right.y}px) rotate(${bgr}deg);"/></g><defs><mask id="a"><path fill="#fff" d="M0 0h600v800H0z"/></mask></defs></svg>`;
return strToObjUrl(svgString);
}

View File

@@ -23,7 +23,7 @@ type EventType = "change" | "error";
type EventsListener = (changes: Change[]) => void;
type ErrorsListener = (error: Error) => void;
interface DiffMonitor {
export interface DiffMonitor {
start: () => void;
stop: () => void;
getChanges: () => Change[];

View File

@@ -190,10 +190,6 @@ watch(
>
<additional-content :rom="currentRom" />
</v-window-item>
<!-- TODO: user screenshots -->
<!-- <v-window-item v-if="rom.user_screenshots.lenght > 0" value="screenshots">
<screenshots :rom="rom" />
</v-window-item> -->
<v-window-item
v-if="
smAndDown &&
@@ -210,8 +206,15 @@ watch(
</v-row>
</v-col>
<v-col cols="auto" v-if="lgAndUp">
<v-container :width="270" class="pa-0">
<v-col
cols="auto"
v-if="
lgAndUp &&
(currentRom.igdb_metadata?.expansions?.length ||
currentRom.igdb_metadata?.dlcs?.length)
"
>
<v-container width="270px" class="pa-0">
<additional-content class="mt-2" :rom="currentRom" />
</v-container>
</v-col>
@@ -224,7 +227,10 @@ watch(
<style scoped>
.title-desktop {
margin-top: -190px;
top: 290px;
left: 350px;
}
#artwork-container {
margin-top: -230px;
}

View File

@@ -9,10 +9,14 @@ import { formatBytes, formatTimestamp, getSupportedEJSCores } from "@/utils";
import { ROUTES } from "@/plugins/router";
import Player from "@/views/Player/EmulatorJS/Player.vue";
import { isNull } from "lodash";
import { onMounted, ref } from "vue";
import { inject, 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 { getEmptyCoverImage } from "@/utils/covers";
const EMULATORJS_VERSION = "4.2.1";
@@ -31,6 +35,7 @@ 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() {
@@ -64,6 +69,50 @@ function onFullScreenChange() {
localStorage.setItem("fullScreenOnPlay", fullScreenOnPlay.value.toString());
}
async function onlyQuit() {
window.history.back();
}
async function saveAndQuit() {
if (!rom.value) return window.history.back();
const screenshotFile = await window.EJS_emulator.gameManager.screenshot();
// Force a save of the current state
const stateFile = window.EJS_emulator.gameManager.getState();
await saveState({
rom: rom.value,
stateFile,
screenshotFile,
});
// Force a save of the save file
const saveFile = window.EJS_emulator.gameManager.getSaveFile();
await saveSave({
rom: rom.value,
save: saveRef.value,
saveFile,
screenshotFile,
});
window.history.back();
}
function saveSelected(save: SaveSchema) {
saveRef.value = save;
localStorage.setItem(
`player:${rom.value?.platform_slug}:save_id`,
save.id.toString(),
);
}
function stateSelected(state: StateSchema) {
stateRef.value = state;
localStorage.setItem(
`player:${rom.value?.platform_slug}:state_id`,
state.id.toString(),
);
}
onMounted(async () => {
const romResponse = await romApi.getRom({
romId: parseInt(route.params.rom as string),
@@ -78,25 +127,8 @@ onMounted(async () => {
supportedCores.value = [...getSupportedEJSCores(rom.value.platform_slug)];
// Load stored bios, save, state, and core
const storedSaveID = localStorage.getItem(`player:${rom.value.id}:save_id`);
if (storedSaveID) {
saveRef.value =
rom.value.user_saves?.find((s) => s.id === parseInt(storedSaveID)) ??
null;
}
const storedStateID = localStorage.getItem(`player:${rom.value.id}:state_id`);
if (storedStateID) {
stateRef.value =
rom.value.user_states?.find((s) => s.id === parseInt(storedStateID)) ??
null;
} else if (rom.value.user_states) {
// Otherwise auto select most recent state by last updated date
stateRef.value =
rom.value.user_states?.sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
)[0] ?? null;
}
saveRef.value = rom.value.user_saves[0] ?? null;
stateRef.value = rom.value.user_states[0] ?? null;
const storedBiosID = localStorage.getItem(
`player:${rom.value.platform_slug}:bios_id`,
@@ -121,6 +153,15 @@ onMounted(async () => {
if (storedDisc) {
discRef.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>
@@ -196,6 +237,7 @@ onMounted(async () => {
"
/>
<v-select
v-if="firmwareOptions.length > 1"
v-model="biosRef"
class="my-1"
hide-details
@@ -209,100 +251,134 @@ onMounted(async () => {
})) ?? []
"
/>
<v-select
v-model="saveRef"
class="my-1"
hide-details
variant="outlined"
clearable
:label="t('common.save')"
:items="
rom.user_saves?.map((s) => ({
title: s.file_name,
subtitle: `${s.emulator} - ${formatBytes(s.file_size_bytes)}`,
value: s,
})) ?? []
"
>
<template #selection="{ item }">
<v-list-item class="pa-0" :title="item.value.file_name ?? ''">
<template #append>
<v-chip size="x-small" class="ml-1" label
>{{ formatBytes(item.value.file_size_bytes) }}
</v-chip>
<v-chip size="small" class="ml-1" label>
{{ formatTimestamp(item.value.updated_at) }}
</v-chip>
</template>
</v-list-item>
</template>
<template #item="{ props, item }">
<v-list-item
class="py-4"
v-bind="props"
:title="item.value.file_name ?? ''"
>
<template #append>
<v-chip size="x-small" class="ml-1" label
>{{ formatBytes(item.value.file_size_bytes) }}
</v-chip>
<v-chip size="small" class="ml-1" label>
{{ formatTimestamp(item.value.updated_at) }}
</v-chip>
</template>
</v-list-item>
</template>
</v-select>
<v-select
v-model="stateRef"
class="my-1"
hide-details
variant="outlined"
clearable
:label="t('common.state')"
:items="
rom.user_states?.map((s) => ({
title: s.file_name,
subtitle: `${s.emulator} - ${formatBytes(s.file_size_bytes)}`,
value: s,
})) ?? []
"
>
<template #selection="{ item }">
<v-list-item class="pa-0" :title="item.value.file_name ?? ''">
<template #append>
<v-chip size="x-small" class="ml-1" color="orange" label>{{
item.value.emulator
}}</v-chip>
<v-chip size="x-small" class="ml-1" label
>{{ formatBytes(item.value.file_size_bytes) }}
</v-chip>
<v-chip size="small" class="ml-1" label>
{{ formatTimestamp(item.value.updated_at) }}
</v-chip>
</template>
</v-list-item>
</template>
<template #item="{ props, item }">
<v-list-item
class="py-4"
v-bind="props"
:title="item.value.file_name ?? ''"
>
<template #append>
<v-chip size="x-small" class="ml-1" color="orange" label>{{
item.value.emulator
}}</v-chip>
<v-chip size="x-small" class="ml-1" label
>{{ formatBytes(item.value.file_size_bytes) }}
</v-chip>
<v-chip size="small" class="ml-1" label>
{{ formatTimestamp(item.value.updated_at) }}
</v-chip>
</template>
</v-list-item>
</template>
</v-select>
<v-row class="mt-2">
<v-col cols="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>
<v-col>
<v-btn
class="w-100"
variant="outlined"
size="large"
@click="emitter?.emit('selectStateDialog', rom)"
>
{{ t("play.select-state") }}
</v-btn>
</v-col>
</v-row>
</v-col>
<v-col cols="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-row>
</v-col>
</v-card-text>
</v-card>
<v-row v-else>
<v-col>
<v-btn
class="w-100"
variant="outlined"
size="large"
@click="emitter?.emit('selectSaveDialog', rom)"
>
{{ t("play.select-save") }}
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row class="px-3 py-3 text-center" no-gutters>
@@ -325,10 +401,8 @@ onMounted(async () => {
>
</v-col>
<v-col
cols="12"
:sm="gameRunning ? 12 : 7"
:xl="gameRunning ? 12 : 9"
:class="gameRunning ? 'mt-2' : 'ml-2'"
:cols="gameRunning ? 12 : 8"
:class="gameRunning ? 'mt-2' : 'ml-4'"
>
<v-btn
color="primary"
@@ -342,42 +416,57 @@ onMounted(async () => {
</v-btn>
</v-col>
</v-row>
<v-row v-if="!gameRunning" class="align-center" no-gutters>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.ROM,
params: { rom: rom?.id },
})
"
>{{ t("play.back-to-game-details") }}
</v-btn>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.PLATFORM,
params: { platform: rom?.platform_id },
})
"
>{{ t("play.back-to-gallery") }}
</v-btn>
</v-row>
<v-btn
v-if="gameRunning"
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-refresh"
@click="$router.go(0)"
>{{ t("play.reset-session") }}
prepend-icon="mdi-exit-to-app"
@click="onlyQuit"
>
{{ t("play.quit") }}
</v-btn>
<v-btn
v-if="gameRunning"
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.ROM,
params: { rom: rom?.id },
})
"
>{{ t("play.back-to-game-details") }}
</v-btn>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.PLATFORM,
params: { platform: rom?.platform_id },
})
"
>{{ t("play.back-to-gallery") }}
prepend-icon="mdi-content-save-move"
@click="saveAndQuit"
>
{{ t("play.save-and-quit") }}
</v-btn>
<cache-dialog v-if="!gameRunning" />
</v-col>

View File

@@ -1,22 +1,29 @@
<script setup lang="ts">
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
import saveApi, { saveApi as api } from "@/services/api/save";
import stateApi from "@/services/api/state";
import type { DetailedRom } from "@/stores/roms";
import { saveApi as api } from "@/services/api/save";
import storeRoms, { type DetailedRom } from "@/stores/roms";
import {
areThreadsRequiredForEJSCore,
getSupportedEJSCores,
getControlSchemeForPlatform,
getDownloadPath,
} from "@/utils";
import createIndexedDBDiffMonitor, {
type Change,
} from "@/utils/indexdb-monitor";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { inject, onBeforeUnmount, onMounted, ref } from "vue";
import { useTheme } from "vuetify";
import {
saveSave,
saveState,
loadEmulatorJSSave,
loadEmulatorJSState,
createQuickLoadButton,
createSaveQuitButton,
} from "./utils";
import type { Emitter } from "mitt";
import type { Events } from "@/types/emitter";
const INVALID_CHARS_REGEX = /[#<$+%>!`&*'|{}/\\?"=@:^\r\n]/gi;
const romsStore = storeRoms();
const props = defineProps<{
rom: DetailedRom;
save: SaveSchema | null;
@@ -27,9 +34,8 @@ const props = defineProps<{
}>();
const romRef = ref<DetailedRom>(props.rom);
const saveRef = ref<SaveSchema | null>(props.save);
const stateRef = ref<StateSchema | null>(props.state);
const theme = useTheme();
const emitter = inject<Emitter<Events>>("emitter");
// Declare global variables for EmulatorJS
declare global {
@@ -47,6 +53,7 @@ declare global {
EJS_gameUrl: string;
EJS_loadStateURL: string | null;
EJS_cheats: string;
EJS_gameParentUrl: string;
EJS_gamePatchUrl: string;
EJS_netplayServer: string;
EJS_alignStartButton: "top" | "center" | "bottom";
@@ -56,10 +63,17 @@ declare global {
EJS_controlScheme: string | null;
EJS_emulator: any; // eslint-disable-line @typescript-eslint/no-explicit-any
EJS_Buttons: Record<string, boolean>;
EJS_VirtualGamepadSettings: {};
EJS_onGameStart: () => void;
EJS_onSaveState: (args: { screenshot: File; state: File }) => void;
EJS_onSaveState: (args: {
screenshot: Uint8Array;
state: Uint8Array;
}) => void;
EJS_onLoadState: () => void;
EJS_onSaveSave: (args: { screenshot: File; save: File }) => void;
EJS_onSaveSave: (args: {
screenshot: Uint8Array;
save: Uint8Array;
}) => void;
EJS_onLoadSave: () => void;
}
}
@@ -94,147 +108,178 @@ window.EJS_defaultOptions = {
window.EJS_gameName = romRef.value.fs_name_no_tags
.replace(INVALID_CHARS_REGEX, "")
.trim();
// Disable quick save and quick load
window.EJS_Buttons = {
quickSave: false,
quickLoad: false,
};
onBeforeUnmount(() => {
window.location.reload();
});
onMounted(() => {
if (props.save) {
localStorage.setItem(
`player:${props.rom.id}:save_id`,
props.save.id.toString(),
);
} else {
localStorage.removeItem(`player:${props.rom.id}:save_id`);
}
if (props.state) {
localStorage.setItem(
`player:${props.rom.id}:state_id`,
props.state.id.toString(),
);
} else {
localStorage.removeItem(`player:${props.rom.id}:state_id`);
}
if (props.bios) {
localStorage.setItem(
`player:${props.rom.platform_slug}:bios_id`,
`player:${romRef.value.platform_slug}:bios_id`,
props.bios.id.toString(),
);
} else {
localStorage.removeItem(`player:${props.rom.platform_slug}:bios_id`);
localStorage.removeItem(`player:${romRef.value.platform_slug}:bios_id`);
}
if (props.core) {
localStorage.setItem(`player:${props.rom.platform_slug}:core`, props.core);
localStorage.setItem(
`player:${romRef.value.platform_slug}:core`,
props.core,
);
} else {
localStorage.removeItem(`player:${props.rom.platform_slug}:core`);
localStorage.removeItem(`player:${romRef.value.platform_slug}:core`);
}
if (props.disc) {
localStorage.setItem(`player:${props.rom.id}:disc`, props.disc.toString());
localStorage.setItem(
`player:${romRef.value.id}:disc`,
props.disc.toString(),
);
} else {
localStorage.removeItem(`player:${props.rom.id}:disc`);
localStorage.removeItem(`player:${romRef.value.id}:disc`);
}
emitter?.on("saveSelected", loadSave);
emitter?.on("stateSelected", loadState);
});
function buildStateName(): string {
const states = romRef.value.user_states?.map((s) => s.file_name) ?? [];
const romName = romRef.value.fs_name_no_ext.trim();
let stateName = `${romName}.state.auto`;
if (!states.includes(stateName)) return stateName;
let i = 1;
stateName = `${romName}.state1`;
while (states.includes(stateName)) {
i++;
stateName = `${romName}.state${i}`;
onBeforeUnmount(async () => {
emitter?.off("saveSelected", loadSave);
emitter?.off("stateSelected", loadState);
window.EJS_emulator?.callEvent("exit");
});
function displayMessage(
message: string,
{
duration,
className = "msg-info",
icon = "",
}: {
duration: number;
className?: "msg-info" | "msg-error" | "msg-success";
icon?: string;
},
) {
window.EJS_emulator.displayMessage(message, duration);
const element = document.querySelector("#game .ejs_message");
if (element) {
element.classList.add(className, icon);
setTimeout(() => {
element.classList.remove(className, icon);
}, duration);
}
return stateName;
}
function buildSaveName(): string {
const saves = romRef.value.user_saves?.map((s) => s.file_name) ?? [];
const romName = romRef.value.fs_name_no_ext.trim();
let saveName = `${romName}.srm`;
if (!saves.includes(saveName)) return saveName;
let i = 2;
saveName = `${romName} (${i}).srm`;
while (saves.includes(saveName)) {
i++;
saveName = `${romName} (${i}).srm`;
}
return saveName;
}
// Saves management
async function loadSave(save: SaveSchema) {
saveRef.value = save;
async function fetchSave(): Promise<Uint8Array> {
if (saveRef.value) {
const { data } = await api.get(
saveRef.value.download_path.replace("/api", ""),
{ responseType: "arraybuffer" },
);
if (data) return new Uint8Array(data);
const { data } = await api.get(save.download_path.replace("/api", ""), {
responseType: "arraybuffer",
});
if (data) {
loadEmulatorJSSave(new Uint8Array(data));
displayMessage("Save loaded from server", {
duration: 3000,
icon: "mdi-cloud-upload",
});
return;
}
const file = await window.EJS_emulator.selectFile();
return new Uint8Array(await file.arrayBuffer());
loadEmulatorJSSave(new Uint8Array(await file.arrayBuffer()));
}
window.EJS_onLoadSave = async function () {
const sav = await fetchSave();
const FS = window.EJS_emulator.gameManager.FS;
const path = window.EJS_emulator.gameManager.getSaveFilePath();
const paths = path.split("/");
let cp = "";
for (let i = 0; i < paths.length - 1; i++) {
if (paths[i] === "") continue;
cp += "/" + paths[i];
if (!FS.analyzePath(cp).exists) FS.mkdir(cp);
}
if (FS.analyzePath(path).exists) FS.unlink(path);
FS.writeFile(path, sav);
window.EJS_emulator.gameManager.loadSaveFiles();
window.EJS_emulator.pause();
window.EJS_emulator.toggleFullscreen(false);
emitter?.emit("selectSaveDialog", romRef.value);
};
async function fetchState(): Promise<Uint8Array> {
if (stateRef.value) {
const { data } = await api.get(
stateRef.value.download_path.replace("/api", ""),
{ responseType: "arraybuffer" },
);
if (data) {
window.EJS_emulator.displayMessage("LOADED FROM ROMM");
return new Uint8Array(data);
}
window.EJS_onSaveSave = async function ({
save: saveFile,
screenshot: screenshotFile,
}) {
const save = await saveSave({
rom: romRef.value,
save: saveRef.value,
saveFile,
screenshotFile,
});
romsStore.update(romRef.value);
if (save) {
displayMessage("Save synced with server", {
duration: 4000,
icon: "mdi-cloud-sync",
});
} else {
displayMessage("Error syncing save with server", {
duration: 4000,
className: "msg-error",
icon: "mdi-sync-alert",
});
}
if (window.EJS_emulator.saveInBrowserSupported()) {
const data = await window.EJS_emulator.storage.states.get(
window.EJS_emulator.getBaseFileName() + ".state",
);
if (data) {
window.EJS_emulator.displayMessage("LOADED FROM BROWSER");
return data;
}
};
// States management
async function loadState(state: StateSchema) {
const { data } = await api.get(state.download_path.replace("/api", ""), {
responseType: "arraybuffer",
});
if (data) {
loadEmulatorJSState(new Uint8Array(data));
displayMessage("State loaded from server", {
duration: 3000,
icon: "mdi-cloud-upload",
});
return;
}
const file = await window.EJS_emulator.selectFile();
return new Uint8Array(await file.arrayBuffer());
loadEmulatorJSState(new Uint8Array(await file.arrayBuffer()));
}
window.EJS_onLoadState = async function () {
const state = await fetchState();
window.EJS_emulator.gameManager.loadState(new Uint8Array(state));
window.EJS_emulator.pause();
window.EJS_emulator.toggleFullscreen(false);
emitter?.emit("selectStateDialog", romRef.value);
};
window.EJS_onSaveState = async function ({
state: stateFile,
screenshot: screenshotFile,
}) {
const state = await saveState({
rom: romRef.value,
stateFile,
screenshotFile,
});
window.EJS_emulator.storage.states.put(
window.EJS_emulator.getBaseFileName() + ".state",
stateFile,
);
romsStore.update(romRef.value);
if (state) {
displayMessage("State synced with server", {
duration: 4000,
icon: "mdi-cloud-sync",
});
} else {
displayMessage("Error syncing state with server", {
duration: 4000,
className: "msg-error",
icon: "mdi-sync-alert",
});
}
};
window.EJS_onGameStart = async () => {
setTimeout(() => {
if (saveRef.value) window.EJS_onLoadSave();
if (stateRef.value) window.EJS_onLoadState();
setTimeout(async () => {
if (props.save) await loadSave(props.save);
if (props.state) await loadState(props.state);
window.EJS_emulator.settings = {
...window.EJS_emulator.settings,
@@ -242,103 +287,49 @@ window.EJS_onGameStart = async () => {
};
}, 10);
const savesMonitor = await createIndexedDBDiffMonitor({
dbName: "/data/saves",
intervalMs: 2000,
});
const statesMonitor = await createIndexedDBDiffMonitor({
dbName: "EmulatorJS-states",
storeName: "states",
intervalMs: 2000,
});
// Start monitoring
savesMonitor.start();
statesMonitor.start();
savesMonitor.on("change", (changes: Change[]) => {
console.log("Save changes detected:", changes);
changes.forEach((change) => {
if (!change.key.includes(window.EJS_gameName)) return;
if (saveRef.value) {
saveApi
.updateSave({
save: saveRef.value,
file: new File(
[change.newValue.contents],
saveRef.value.file_name,
{
type: "application/octet-stream",
},
),
})
.then(({ data }) => {
saveRef.value = data;
const quickLoad = createQuickLoadButton();
quickLoad.addEventListener("click", () => {
if (
window.EJS_emulator.settings["save-state-location"] === "browser" &&
window.EJS_emulator.saveInBrowserSupported()
) {
window.EJS_emulator.storage.states
.get(window.EJS_emulator.getBaseFileName() + ".state")
.then((e: Uint8Array) => {
window.EJS_emulator.gameManager.loadState(e);
displayMessage("Quick load from server", {
duration: 3000,
icon: "mdi-flash",
});
} else {
saveApi
.uploadSaves({
rom: romRef.value,
emulator: window.EJS_core,
saves: [
new File([change.newValue.contents], buildSaveName(), {
type: "application/octet-stream",
}),
],
})
.then(({ data }) => {
const allSaves = data.saves.sort(
(a: SaveSchema, b: SaveSchema) => a.id - b.id,
);
if (romRef.value) romRef.value.user_saves = allSaves;
saveRef.value = allSaves.pop() ?? null;
})
.catch();
}
});
});
}
});
statesMonitor.on("change", (changes: Change[]) => {
console.log("State changes detected:", changes);
const saveAndQuit = createSaveQuitButton();
saveAndQuit.addEventListener("click", async () => {
if (!romRef.value || !window.EJS_emulator) return window.history.back();
changes.forEach((change) => {
if (!change.key.includes(window.EJS_gameName)) return;
const stateFile = window.EJS_emulator.gameManager.getState();
const saveFile = window.EJS_emulator.gameManager.getSaveFile();
const screenshotFile = await window.EJS_emulator.gameManager.screenshot();
if (stateRef.value) {
stateApi
.updateState({
state: stateRef.value,
file: new File([change.newValue], stateRef.value.file_name, {
type: "application/octet-stream",
}),
})
.then(({ data }) => {
stateRef.value = data;
})
.catch();
} else {
stateApi
.uploadStates({
rom: romRef.value,
emulator: window.EJS_core,
states: [
new File([change.newValue], buildStateName(), {
type: "application/octet-stream",
}),
],
})
.then(({ data }) => {
const allStates = data.states.sort(
(a: StateSchema, b: StateSchema) => a.id - b.id,
);
if (romRef.value) romRef.value.user_states = allStates;
stateRef.value = allStates.pop() ?? null;
})
.catch();
}
// Force a save of the current state
await saveState({
rom: romRef.value,
stateFile,
screenshotFile,
});
// Force a save of the save file
await saveSave({
rom: romRef.value,
save: saveRef.value,
saveFile,
screenshotFile,
});
romsStore.update(romRef.value);
window.history.back();
});
};
</script>
@@ -362,20 +353,45 @@ window.EJS_onGameStart = async () => {
height: fit-content;
}
#game .ejs_setting_menu .ejs_settings_main_bar:nth-child(3) {
display: none;
}
#game .ejs_game_background {
background-size: 40%;
}
/* Hide the exit button */
#game .ejs_menu_bar .ejs_menu_button:nth-child(-1) {
display: none;
}
#game .ejs_message {
visibility: hidden;
margin: 1rem;
padding: 0.25rem 0.75rem;
border-radius: 4px;
color: white;
text-transform: uppercase;
display: flex;
align-items: center;
filter: opacity(0.85) drop-shadow(0 0 0.5rem rgba(0, 0, 0, 0.5));
}
#game .ejs_message::before {
margin-right: 8px;
font-size: 20px !important;
font: normal normal normal 24px / 1 "Material Design Icons";
}
#game .ejs_message.msg-info {
visibility: visible;
background-color: rgba(var(--v-theme-romm-blue));
}
#game .ejs_message.msg-error {
visibility: visible;
background-color: rgba(var(--v-theme-romm-red));
}
#game .ejs_message.msg-success {
visibility: visible;
background-color: rgba(var(--v-theme-romm-green));
}
</style>
<!-- Other config options: https://emulatorjs.org/docs/options -->
<!-- window.EJS_biosUrl; -->
<!-- window.EJS_VirtualGamepadSettings; -->
<!-- window.EJS_cheats; -->
<!-- window.EJS_gamePatchUrl; -->
<!-- window.EJS_gameParentUrl; -->
<!-- window.EJS_netplayServer; -->

View File

@@ -0,0 +1,180 @@
import saveApi from "@/services/api/save";
import stateApi from "@/services/api/state";
import { type DetailedRom } from "@/stores/roms";
import { type SaveSchema } from "@/__generated__";
import { type StateSchema } from "@/__generated__";
function buildStateName(rom: DetailedRom): string {
const romName = rom.fs_name_no_ext.trim();
return `${romName} [${new Date().toISOString().replace(/[:.]/g, "-").replace("T", " ").replace("Z", "")}]`;
}
function buildSaveName(rom: DetailedRom): string {
const romName = rom.fs_name_no_ext.trim();
return `${romName} [${new Date().toISOString().replace(/[:.]/g, "-").replace("T", " ").replace("Z", "")}]`;
}
export async function saveState({
rom,
stateFile,
screenshotFile,
}: {
rom: DetailedRom;
stateFile: Uint8Array;
screenshotFile?: Uint8Array;
}): Promise<StateSchema | null> {
const filename = buildStateName(rom);
try {
const uploadedStates = await stateApi.uploadStates({
rom: rom,
emulator: window.EJS_core,
statesToUpload: [
{
stateFile: new File([stateFile], `${filename}.state`, {
type: "application/octet-stream",
}),
screenshotFile: screenshotFile
? new File([screenshotFile], `${filename}.png`, {
type: "application/octet-stream",
})
: undefined,
},
],
});
const uploadedState = uploadedStates[0];
if (uploadedState.status == "fulfilled") {
if (rom) rom.user_states.unshift(uploadedState.value);
return uploadedState.value;
}
} catch (error) {
console.error("Failed to upload state", error);
}
return null;
}
export async function saveSave({
rom,
save,
saveFile,
screenshotFile,
}: {
rom: DetailedRom;
save: SaveSchema | null;
saveFile: Uint8Array;
screenshotFile?: Uint8Array;
}): Promise<SaveSchema | null> {
if (save) {
try {
const { data: updateSave } = await saveApi.updateSave({
save: save,
saveFile: new File([saveFile], save.file_name, {
type: "application/octet-stream",
}),
});
return updateSave;
} catch (error) {
console.error("Failed to update save", error);
return null;
}
}
const filename = buildSaveName(rom);
try {
const uploadedSaves = await saveApi.uploadSaves({
rom: rom,
emulator: window.EJS_core,
savesToUpload: [
{
saveFile: new File([saveFile], `${filename}.srm`, {
type: "application/octet-stream",
}),
screenshotFile: screenshotFile
? new File([screenshotFile], `${filename}.png`, {
type: "application/octet-stream",
})
: undefined,
},
],
});
const uploadedSave = uploadedSaves[0];
if (uploadedSave.status == "fulfilled") {
if (rom) rom.user_saves.unshift(uploadedSave.value);
return uploadedSave.value;
}
} catch (error) {
console.error("Failed to upload save", error);
}
return null;
}
export function loadEmulatorJSSave(save: Uint8Array) {
const FS = window.EJS_emulator.gameManager.FS;
const path = window.EJS_emulator.gameManager.getSaveFilePath();
const paths = path.split("/");
let cp = "";
for (let i = 0; i < paths.length - 1; i++) {
if (paths[i] === "") continue;
cp += "/" + paths[i];
if (!FS.analyzePath(cp).exists) FS.mkdir(cp);
}
if (FS.analyzePath(path).exists) FS.unlink(path);
FS.writeFile(path, save);
window.EJS_emulator.gameManager.loadSaveFiles();
}
export function loadEmulatorJSState(state: Uint8Array) {
window.EJS_emulator.gameManager.loadState(state);
}
export function createQuickLoadButton(): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("role", "presentation");
svg.setAttribute("focusable", "false");
svg.setAttribute("viewBox", "2 2 20 20");
svg.innerHTML =
'<path d="M12,7L17,12H14V16H10V12H7L12,7M19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21M19,19V5H5V19H19Z"></path>';
const text = document.createElement("span");
text.classList.add("ejs_menu_text");
text.innerText = "Load Latest State";
button.classList.add("ejs_menu_button");
button.appendChild(svg);
button.appendChild(text);
const ejsMenuBar = document.querySelector("#game .ejs_menu_bar");
const loadStateBtn = ejsMenuBar?.querySelector(
".ejs_menu_button:nth-child(5)",
);
if (ejsMenuBar && loadStateBtn) {
ejsMenuBar.insertBefore(button, loadStateBtn);
}
return button;
}
export function createSaveQuitButton(): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("role", "presentation");
svg.setAttribute("focusable", "false");
svg.setAttribute("viewBox", "2 2 20 20");
svg.innerHTML =
'<path d="M17,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H11.81C11.42,20.34 11.17,19.6 11.07,18.84C9.5,18.31 8.66,16.6 9.2,15.03C9.61,13.83 10.73,13 12,13C12.44,13 12.88,13.1 13.28,13.29C15.57,11.5 18.83,11.59 21,13.54V7L17,3M15,9H5V5H15V9M13,17H17V14L22,18.5L17,23V20H13V17"></path>';
const text = document.createElement("span");
text.classList.add("ejs_menu_text", "ejs_menu_text_right");
text.innerText = "Save & Quit";
button.classList.add("ejs_menu_button");
button.appendChild(svg);
button.appendChild(text);
const ejsMenuBar = document.querySelector("#game .ejs_menu_bar");
ejsMenuBar?.appendChild(button);
return button;
}

View File

@@ -60,6 +60,10 @@ function onFullScreenChange() {
localStorage.setItem("fullScreenOnPlay", fullScreenOnPlay.value.toString());
}
async function onlyQuit() {
window.history.back();
}
onMounted(async () => {
const romResponse = await romApi.getRom({
romId: parseInt(route.params.rom as string),
@@ -148,42 +152,46 @@ onMounted(async () => {
</v-btn>
</v-col>
</v-row>
<v-row v-if="!gameRunning" class="align-center" no-gutters>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.ROM,
params: { rom: rom?.id },
})
"
>{{ t("play.back-to-game-details") }}
</v-btn>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.PLATFORM,
params: { platform: rom?.platform_id },
})
"
>{{ t("play.back-to-gallery") }}
</v-btn>
</v-row>
<v-btn
v-if="gameRunning"
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-refresh"
@click="$router.go(0)"
>{{ t("play.reset-session") }}
</v-btn>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.ROM,
params: { rom: rom?.id },
})
"
>{{ t("play.back-to-game-details") }}
</v-btn>
<v-btn
class="mt-4"
block
variant="outlined"
size="large"
prepend-icon="mdi-arrow-left"
@click="
$router.push({
name: ROUTES.PLATFORM,
params: { platform: rom?.platform_id },
})
"
>{{ t("play.back-to-gallery") }}
prepend-icon="mdi-exit-to-app"
@click="onlyQuit"
>
{{ t("play.quit") }}
</v-btn>
</v-col>
</v-row>

View File

@@ -5,6 +5,7 @@ env =
DB_NAME=romm_test
DB_USER=romm_test
DB_PASSWD=passwd
ROMM_DB_DRIVER=mariadb
IGDB_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
IGDB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ROMM_AUTH_SECRET_KEY=843f6cefc5ba1430d54061301c2893be00c2aef11dae39ffec13a2af1a86e867