mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
Merge pull request #1790 from rommapp/emujs-save-file-fix
Save and state UI and emulation overhaul
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
6
frontend/src/__generated__/index.ts
generated
6
frontend/src/__generated__/index.ts
generated
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
163
frontend/src/components/common/Game/Dialog/Asset/DeleteSaves.vue
Normal file
163
frontend/src/components/common/Game/Dialog/Asset/DeleteSaves.vue
Normal 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>
|
||||
@@ -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>
|
||||
124
frontend/src/components/common/Game/Dialog/Asset/SelectSave.vue
Normal file
124
frontend/src/components/common/Game/Dialog/Asset/SelectSave.vue
Normal 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>
|
||||
124
frontend/src/components/common/Game/Dialog/Asset/SelectState.vue
Normal file
124
frontend/src/components/common/Game/Dialog/Asset/SelectState.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "ステートを選択"
|
||||
}
|
||||
|
||||
@@ -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": "상태 선택"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Выбрать состояние"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"full-screen": "全屏",
|
||||
"play": "游玩",
|
||||
"reset-session": "重置会话",
|
||||
"quit": "退出",
|
||||
"save-and-quit": "保存并退出",
|
||||
"back-to-game-details": "返回游戏详情",
|
||||
"back-to-gallery": "返回游戏库",
|
||||
"clear-cache": "清除 EmulatorJS 缓存",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
frontend/src/types/emitter.d.ts
vendored
5
frontend/src/types/emitter.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; -->
|
||||
|
||||
180
frontend/src/views/Player/EmulatorJS/utils.ts
Normal file
180
frontend/src/views/Player/EmulatorJS/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user