feat: Moved to stream zip downloading.

feat: Implemented Shortcuts.
feat: Ensured it works on steam deck
This commit is contained in:
Simeon Radivoev 2026-02-21 18:28:07 +02:00
parent f15bf9a1e0
commit 62f16cbcc1
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
45 changed files with 1415 additions and 631 deletions

22
.vscode/launch.json vendored
View file

@ -4,29 +4,31 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Edge",
"port": 9222,
"request": "attach",
"type": "msedge",
"webRoot": "${workspaceFolder}/src",
},
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "attach",
"name": "Attach Bun",
"url": "ws://127.0.0.1:9229/54esztvxlfe",
"url": "ws://127.0.0.1:9229/fixed-session",
"localRoot": "${workspaceFolder}",
"stopOnEntry": false,
},
{
"type": "chrome",
"request": "attach",
"name": "Attach To Browser",
"url": "http://192.168.1.190:5173/",
"webRoot": "${workspaceFolder}/src/mainview",
"address": "localhost",
"outputCapture": "console",
"port": 9222
}
],
"compounds": [
{
"name": "Attach Debug App",
"configurations": [
"Attach Bun",
"Attach to Edge"
"Attach Bun"
],
"stopAll": true,
"preLaunchTask": "bun: dev"

View file

@ -29,5 +29,14 @@
"norigin",
"noriginmedia",
"romm"
],
"terminal.integrated.env.linux": {
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"GPG_TTY": "/dev/tty"
},
"terminal.integrated.shellArgs.linux": [
"-l"
]
}

37
.vscode/tasks.json vendored
View file

@ -5,7 +5,12 @@
"label": "Run Act",
"type": "shell",
"command": "act",
"args": ["--artifact-server-path", "artifacts", "-W", ".github/workflows/build.yml"],
"args": [
"--artifact-server-path",
"artifacts",
"-W",
".github/workflows/build.yml"
],
"options": {
"env": {
"PATH": "${env:PATH}",
@ -13,6 +18,36 @@
},
},
"problemMatcher": []
},
{
"label": "Start Dev",
"type": "shell",
"command": "bun run dev",
"isBackground": true,
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
},
{
"label": "Start Dev (Hot Reload)",
"type": "shell",
"command": "bun run dev:hmr",
"isBackground": true,
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
}
]
}

View file

@ -21,6 +21,7 @@
"pathe": "^2.0.3",
"tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"zod": "^4.3.6",
},
"devDependencies": {
@ -37,6 +38,7 @@
"@types/bun": "latest",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2",
"animate.css": "^4.1.1",
"babel-plugin-react-compiler": "^1.0.0",
@ -411,6 +413,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unzip-stream": ["@types/unzip-stream@0.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@ -443,6 +447,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="],
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@ -453,6 +459,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -461,6 +469,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
@ -717,6 +727,10 @@
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -925,6 +939,8 @@
"tough-cookie-file-store": ["tough-cookie-file-store@3.3.0", "", { "dependencies": { "tough-cookie": "^6.0.0" } }, "sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g=="],
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@ -947,6 +963,8 @@
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],

View file

@ -0,0 +1,17 @@
{
"folders": [
{
"path": "."
},
{
"path": "../../../../run/media/deck/a545d555-e643-4d7e-9a29-8103abc18328/gameflow"
}
],
"settings": {
"terminal.integrated.env.linux": {
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000"
}
}
}

View file

@ -5,7 +5,7 @@
"description": "Game Launcher",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun run build && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' bun run --inspect=127.0.0.1:9229 --watch ./src/bun/index.ts",
"dev": "NODE_ENV=development bun run build && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' bun run ./scripts/dev.ts",
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run dev'",
"build": "vite build",
"build:pro": "NODE_ENV=production bun run build",
@ -37,6 +37,7 @@
"pathe": "^2.0.3",
"tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"zod": "^4.3.6"
},
"devDependencies": {
@ -53,6 +54,7 @@
"@types/bun": "latest",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2",
"animate.css": "^4.1.1",
"babel-plugin-react-compiler": "^1.0.0",

36
scripts/dev.ts Normal file
View file

@ -0,0 +1,36 @@
// watcher.ts - run this instead of --watch
import EventEmitter from "events";
import { watch } from "fs";
import browser from '../src/bun/browser';
const events = new EventEmitter();
function spawnServer ()
{
return Bun.spawn(["bun", "run", "--inspect=127.0.0.1:9229/fixed-session", '--watch', "./src/bun/index.ts"], {
env: {
...Bun.env,
HEADLESS: "true"
},
ipc (message, subprocess, handle)
{
if (message.type === 'exitapp')
{
events.emit('exitapp');
}
},
});
}
function spawnBrowser ()
{
try
{
return browser(events, false);
} catch (error)
{
console.error(error);
};
}
spawnServer();
spawnBrowser();

View file

@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { SERVER_URL, SettingsSchema, SettingsType } from "../../shared/constants";
import { Notification, SERVER_URL, SettingsSchema, SettingsType } from "@shared/constants";
import { client } from "@clients/romm/client.gen";
import * as schema from "./schema/app";
import * as emulatorSchema from "./schema/emulators";
@ -18,6 +18,7 @@ import os from 'node:os';
import { ActiveGame } from "../types/types";
import EventEmitter from "node:events";
import { ErrorLike } from "bun";
import { getErrorMessage } from "../utils";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
@ -58,7 +59,14 @@ export function setActiveGame (game: ActiveGame)
return activeGame = game;
}
export const events = new EventEmitter<AppEventMap>();
events.addListener('activegameexit', () => activeGame = undefined);
events.addListener('activegameexit', ({ error }) =>
{
activeGame = undefined;
if (error)
{
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
}
});
console.log("Logging In to Romm");
export async function cleanup ()
@ -71,6 +79,7 @@ export async function cleanup ()
interface AppEventMap
{
activegameexit: [{ subprocess: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
activegameexit: [{ subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
exitapp: [];
notification: [Notification];
}

View file

@ -5,12 +5,14 @@ import z from "zod";
import * as schema from "../schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { getErrorMessage } from "@/bun/utils";
import { spawn } from "node:child_process";
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
@ -215,17 +217,70 @@ export default new Elysia()
const localGame = await db.query.games.findFirst({
where: eq(schema.games.id, validCommand.gameId), columns: {
name: true
name: true,
source_id: true,
source: true
}
});
try
{
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand.command.command, {
shell: true
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
{
events.emit('activegameexit', { exitCode: code, signalCode: null });
resolve(code);
});
game.on('error', e =>
{
events.emit('activegameexit', { exitCode: null, signalCode: null, error: e });
console.error(e);
});
setActiveGame({
pid: game.pid,
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
function updateRommProps (id: number)
{
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
events.emit('notification', { message: "Updated Last Played", type: 'success' });
}
if (source === 'romm')
{
updateRommProps(id);
}
else if (localGame?.source === 'romm' && localGame.source_id)
{
updateRommProps(localGame.source_id);
}
});
/*
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
@ -236,8 +291,15 @@ export default new Elysia()
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}
}*/
return status('OK');
} catch (error)
{
return status('Internal Server Error', getErrorMessage(error));
}
}
}
}, {

View file

@ -1,11 +1,12 @@
import path, { basename, dirname } from 'node:path';
import path from 'node:path';
import { which } from 'bun';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '../../schema/emulators';
import { eq } from 'drizzle-orm';
import { config, emulatorsDb } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
export const varRegex = /%([^%]+)%/g;
@ -78,13 +79,47 @@ export async function getValidLaunchCommands (data: {
const formattedCommands = await Promise.all(system.commands.map(async command =>
{
const label = command.label;
const cmd = command.command;
let cmd = command.command;
const matches = cmd.match(varRegex);
if (matches)
{
let emulator: string | undefined = undefined;
const varList = await Promise.all(matches.map(async (value) =>
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': validFiles[0].replace('/', '\\'),
'%ESPATH%': path.dirname(Bun.main),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])),
'%FILENAME%': path.basename(validFiles[0])
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
{
try
{
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
@ -96,22 +131,27 @@ export async function getValidLaunchCommands (data: {
}
emulator = emulatorName;
return [value, exec];
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
}
const key = value.substring(1, value.length - 1);
return [value, process.env[key]];
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
}));
const vars = Object.fromEntries(varList);
vars['%ROM%'] = validFiles[0];
vars['%ESPATH%'] = config.get('downloadPath');
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
}
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
@ -165,8 +205,8 @@ export async function findExec (emulatorName: string)
async function readRegistryValue (text: string)
{
const params = text.split('|');
const key = dirname(params[0]);
const value = basename(params[0]);
const key = path.dirname(params[0]);
const value = path.basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({
@ -197,9 +237,10 @@ async function resolveStaticPath (entries: string[])
{
for (const entry of entries)
{
for await (const match of fs.glob(entry))
const resolved = entry.replace("~", os.homedir());
if (await fs.exists(resolved))
{
return match;
return resolved;
}
}
return null;

View file

@ -1,10 +1,13 @@
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
import { activeGame, customEmulators, db, events, taskQueue } from "../../app";
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
import * as schema from '../../schema/app';
import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils";
import { getLocalGameMatch } from "./utils";
import { getRomApiRomsIdGet } from "@/clients/romm";
import fs from 'node:fs/promises';
import { ErrorLike } from "elysia/universal";
class CommandSearchError extends Error
{
@ -116,18 +119,35 @@ export default async function buildStatusResponse (source: string, id: number)
enqueue({ status: 'installed', details: validCommand.command.label });
}
} else if (source === 'romm')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
}
}
}
}
await sendLatests();
const dispose: Function[] = [];
const handleActiveExit = async () =>
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
{
if (data.error)
{
enqueue({
status: 'error',
error: data.error
}, 'error');
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);

View file

@ -1,14 +1,17 @@
import { IJob, JobContext } from "../task-queue";
import { mkdir } from 'node:fs/promises';
import { eq, or } from 'drizzle-orm';
import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises';
import { DownloaderHelper } from 'node-downloader-helper';
import StreamZip from 'node-stream-zip';
import * as schema from "../schema/app";
import * as emulatorSchema from "../schema/emulators";
import path from 'node:path';
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
import { downloadRomsApiRomsDownloadGet, getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
import { config, db, emulatorsDb, jar } from "../app";
import unzip from 'unzip-stream';
import { Readable, Transform } from "node:stream";
import { createWriteStream } from "node:fs";
interface JobConfig
{
@ -39,6 +42,7 @@ export class InstallJob implements IJob
if (this.config?.dryDownload !== true)
{
/*
// download files for rom
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
@ -84,7 +88,38 @@ export class InstallJob implements IJob
await zip.extract(null, downloadPath);
await zip.close();
await fs.rm(zipFilePath);
await fs.rm(zipFilePath);*/
cx.setProgress(0, 'download');
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
const res = await fetch(downloadUrl, {
headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
},
});
const totalBytes = Number(res.headers.get("content-length")) || 0;
let bytesReceived = 0;
const progressStream = new Transform({
transform (chunk, encoding, callback)
{
bytesReceived += chunk.length;
if (totalBytes > 0)
{
const percent = (bytesReceived / totalBytes) * 100;
cx.setProgress(percent, 'download');
}
this.push(chunk);
callback();
}
});
await new Promise((resolve, reject) =>
{
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject);
});
}
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
@ -115,10 +150,9 @@ export class InstallJob implements IJob
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
const esPlatform = await emulatorsDb
.select({ slug: emulatorSchema.systems.name, romm_slug: emulatorSchema.systemMappings.sourceSlug })
.from(emulatorSchema.systems)
.leftJoin(emulatorSchema.systemMappings, eq(emulatorSchema.systemMappings.source, 'romm'))
.where(eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug));
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
.from(emulatorSchema.systemMappings)
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;

View file

@ -0,0 +1,28 @@
import { Notification } from '@shared/constants';
import { events } from './app';
export default function buildNotificationsStream ()
{
let cleanup: (() => void) | undefined = undefined;
return new ReadableStream({
async start (controller)
{
function enqueue (data: Notification, event?: 'notification')
{
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
}
const notificationHandler = (notification: Notification) =>
{
enqueue(notification, 'notification');
};
events.on('notification', notificationHandler);
cleanup = () => events.removeListener('notification', notificationHandler);
},
cancel: () =>
{
cleanup?.();
}
});
}

View file

@ -2,8 +2,10 @@ import Elysia from "elysia";
import open from 'open';
import z from "zod";
import os from 'node:os';
import { events } from "./app";
import { config, events } from "./app";
import { isSteamDeckGameMode } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
// steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
export const system = new Elysia({ prefix: '/api/system' })
@ -14,8 +16,11 @@ export const system = new Elysia({ prefix: '/api/system' })
open('steam://open/keyboard');
}
})
.get('/info', () =>
.get('/info', async () =>
{
const downloadStats = await fs.statfs(config.get('downloadPath'));
return {
homeDir: os.homedir(),
user: os.userInfo().username,
@ -23,16 +28,21 @@ export const system = new Elysia({ prefix: '/api/system' })
platform: os.platform(),
hostname: os.hostname(),
steamDeck: process.env.SteamDeck,
machine: os.machine()
machine: os.machine(),
freeSpace: downloadStats.bsize * downloadStats.bavail,
totalSpace: downloadStats.bsize * downloadStats.blocks,
downloadsType: downloadStats.type
};
})
.get('/notifications', ({ set }) =>
{
set.headers["content-type"] = 'text/event-stream';
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return new Response(buildNotificationsStream());
})
.post('/exit', () =>
{
if (process.env.PUBLIC_ACCESS)
{
return;
}
events.emit('exitapp');
})
.post('/open', async ({ query: { url } }) =>

View file

@ -48,12 +48,14 @@ export class TaskQueue
public waitForJob (id: string): Promise<void>
{
return this.queue?.find(j => j.context.id === id)?.promise ?? Promise.resolve();
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
return job?.promise ?? Promise.resolve();
}
public findJob (id: string): IPublicJob | undefined
{
return this.queue?.find(j => j.context.id === id)?.context;
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
return job?.context;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void

100
src/bun/browser.ts Normal file
View file

@ -0,0 +1,100 @@
import { killBrowser, spawnBrowser } from './utils/browser-spawner';
import { BuildParams } from './utils/browser-params';
import os from 'node:os';
import { EventEmitter } from 'node:stream';
export default async function init (events: EventEmitter, forceBrowser: boolean)
{
if (forceBrowser)
{
await runBrowser(events);
} else
{
try
{
await runWebview(events);
} catch (error)
{
await runBrowser(events);
}
}
}
async function runWebview (events: EventEmitter)
{
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
smol: true,
});
return new Promise((resolve, reject) =>
{
webviewWorker.addEventListener('error', e =>
{
console.error(e.message);
reject(e.error);
});
webviewWorker.addEventListener('message', (e) =>
{
if (e.data === 'destroyed')
{
resolve(true);
}
});
events.on('exitapp', () =>
{
resolve(true);
});
});
}
async function runBrowser (events: EventEmitter)
{
const browserParams = await BuildParams();
if (!browserParams)
{
console.error("Could not find valid browser");
return Promise.resolve();
}
else if (!Bun.env.HEADLESS)
{
return new Promise((resolve) =>
{
spawnBrowser({
browser: browserParams.browser.type,
args: browserParams.args,
env: browserParams.env,
detached: false,
execPath: browserParams.browser.path,
source: browserParams.browser.source,
ipc (message)
{
console.log(message);
},
onExit: () => resolve(true)
}).then(browser =>
{
events.on('exitapp', () =>
{
killBrowser(browser);
resolve(true);
});
}).catch(e =>
{
console.error(e);
resolve(e);
});
});
} else
{
return new Promise(resolve =>
{
events.on('exitapp', () =>
{
resolve(true);
});
});
}
}

View file

@ -1,9 +1,7 @@
import { RunBunServer } from './server';
import { RunAPIServer } from './api/rpc';
import { spawnBrowser } from './utils/browser-spawner';
import { BuildParams } from './utils/browser-params';
import { cleanup as appCleanup, events } from './api/app';
import os from 'node:os';
import init from './browser';
const api = RunAPIServer();
let bunServer: { stop: () => void; url: URL; } | undefined;
@ -15,6 +13,7 @@ if (!Bun.env.PUBLIC_ACCESS)
async function cleanup ()
{
console.log("Cleaning Up");
await appCleanup();
bunServer?.stop();
await api.apiServer.stop();
@ -22,73 +21,19 @@ async function cleanup ()
process.exit(0);
}
if (Bun.env.FORCE_BROWSER)
if (Bun.env.HEADLESS)
{
await runBrowser();
} else
{
try
{
await runWebview();
} catch (error)
{
await runBrowser();
}
}
async function runWebview ()
{
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
smol: true,
});
await new Promise((resolve, reject) =>
{
webviewWorker.addEventListener('error', e =>
{
console.error(e.message);
reject(e.error);
});
webviewWorker.addEventListener('message', (e) =>
{
if (e.data === 'destroyed')
{
resolve(true);
}
});
events.on('exitapp', () =>
{
resolve(true);
process.send?.({ type: 'exitapp' });
cleanup();
});
});
await cleanup();
}
async function runBrowser ()
{
const browserParams = await BuildParams();
if (!browserParams)
{
console.error("Could not find valid browser");
await cleanup();
} else
{
const browser = spawnBrowser({
browser: browserParams.browser.type,
args: browserParams.args,
env: browserParams.env,
detached: false,
execPath: browserParams.browser.path,
source: browserParams.browser.source,
ipc (message)
{
console.log(message);
},
onExit: cleanup
});
await init(events, !!Bun.env.FORCE_BROWSER);
await cleanup();
}
events.on('exitapp', () => browser.kill(15));
}
}

View file

@ -1,7 +1,7 @@
declare const IS_BINARY: string;
export type ActiveGame = {
process: Bun.Subprocess;
pid?: number;
gameId: number;
name: string;
command: string;

View file

@ -59,6 +59,12 @@ export async function BuildParams ()
args.push('--disabled-features=WindowControlsOverlay,navigationControls,Translate,msUndersideButton');
args.push(`--profile-directory=Default`);
if (Bun.env.NODE_ENV !== 'production')
{
args.push('--auto-open-devtools-for-tabs');
args.push('--remote-debugging-port=9222');
}
if (config.has('windowPosition'))
{
args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`);

View file

@ -1,4 +1,8 @@
import { type Subprocess } from "bun";
import { $, type Subprocess } from "bun";
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import os from 'node:os';
export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
export type RunBrowserSource = "running" | "system" | "flatpak";
@ -25,6 +29,11 @@ interface SpawnBrowserOptions
ipc?: (message: string) => void;
}
interface SpawnLastInfo
{
PID: number;
}
/**
* Spawns a browser process with proper handling for different installation types.
*
@ -52,7 +61,7 @@ interface SpawnBrowserOptions
* });
* }
*/
export function spawnBrowser ({
export async function spawnBrowser ({
browser,
args = [],
env = {},
@ -61,9 +70,8 @@ export function spawnBrowser ({
source,
onExit,
ipc
}: SpawnBrowserOptions): Subprocess
}: SpawnBrowserOptions): Promise<Subprocess>
{
// Configuration for both Flatpak and Native
// Contains Flatpak app IDs, internal container paths, and fallback binary names
const config: Record<RunBrowserType, { id: string; internalCmd: string; bin: string[]; }> = {
@ -136,11 +144,14 @@ export function spawnBrowser ({
console.log(`[Browser] Launching Native: ${execPath}`);
}
const { signal } = new AbortController();
const processSub = Bun.spawn(cmd, {
env: finalEnv,
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
detached,
signal,
ipc,
onExit (_proc, exitCode)
{
@ -157,6 +168,17 @@ export function spawnBrowser ({
return processSub;
}
export async function killBrowser (browser: Subprocess)
{
if (os.platform() === 'linux')
{
// kill chrome by your unique identifier
await $`pkill -KILL -P ${browser.pid}`.quiet().nothrow();
} else
{
browser?.kill(15);
}
}
// --- Test Run ---
// spawnBrowser({

View file

@ -3,11 +3,11 @@ import
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { FrontEndId, GameMeta } from "../../shared/constants";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardParams } from "./GameCard";
import { JSX, useState } from "react";
import classNames from "classnames";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
export interface GameMetaExtra extends GameMeta
{
@ -22,7 +22,7 @@ export function CardList (data: {
games: GameMetaExtra[];
grid?: boolean;
onSelectGame?: (id: string) => void;
onGameFocus?: (id: string) => void;
onGameFocus?: (id: string, node: HTMLElement) => void;
className?: string;
})
{
@ -30,13 +30,21 @@ export function CardList (data: {
focusKey: data.id,
});
function BuildGame (g: GameMetaExtra, i: number)
function BuildCard (g: GameMetaExtra, i: number)
{
let preview: GameCardParams['preview'] = g.preview;
if (!preview && g.previewUrl)
{
preview = g.previewUrl;
}
const handleAction = () =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
};
useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
return (
<GameCard
key={g.id}
@ -46,17 +54,12 @@ export function CardList (data: {
data-index={i}
title={g.title}
subtitle={g.subtitle ?? ""}
onFocus={() =>
onFocus={(id, node) =>
{
g.onFocus?.();
data.onGameFocus?.(g.id);
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
}}
onAction={() =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
data.onGameFocus?.(id, node);
}}
onAction={handleAction}
preview={preview}
badges={g.badges}
id={g.id}
@ -82,7 +85,7 @@ export function CardList (data: {
style={{ scrollbarWidth: "none" }}
>
<FocusContext.Provider value={focusKey}>
{data.games.map(BuildGame)}
{data.games.map(BuildCard)}
</FocusContext.Provider>
</ul>
);

View file

@ -0,0 +1,54 @@
import { getCollectionsApiCollectionsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, RPC_URL } from "@/shared/constants";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation";
export default function CollectionList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: (node: HTMLElement) => void;
})
{
const navigate = useNavigate();
const { data: collections } = useSuspenseQuery({
...getCollectionsApiCollectionsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime
});
return (
<CardList
type="collection"
id={data.id}
className={data.className}
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
.map((g) => ({
id: String(g.id),
title: g.name,
focusKey: `collection-${g.id}`,
subtitle: g.user__username,
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.rom_count}
</span>
],
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
SaveSource('game-list');
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onGameFocus={(id, node) =>
{
data.setBackground(
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
);
data.onFocus?.(node);
}}
/>
);
}

View file

@ -6,6 +6,9 @@ import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
export interface CollectionsDetailParams
{
@ -17,6 +20,16 @@ export interface CollectionsDetailParams
footer?: JSX.Element;
}
function HandleGoBack ()
{
const source = PopSource('game-list');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
@ -25,6 +38,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`,
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
return (
<FocusContext value={focusKey}>
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
@ -44,7 +60,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div>
{data.footer}
</div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
<Shortcuts shortcuts={shortcuts} />
</footer>
</AnimatedBackground>
</FocusContext>

View file

@ -4,6 +4,7 @@ import { createContext, JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { useEventListener } from "usehooks-ts";
import { X } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as {
close: () => void,
@ -75,14 +76,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
}
}, [data.open]);
useEventListener('cancel', (e) =>
useShortcuts(focusKey, () => [{
label: "Close",
button: GamePadButtonCode.B,
action: () =>
{
if (data.open)
{
e.stopPropagation();
data.close();
}
}, ref);
}], []);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",

View file

@ -5,6 +5,8 @@ import
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import classNames from "classnames";
import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
function FilterCat (
data: {
@ -12,14 +14,25 @@ function FilterCat (
children?: any;
active: boolean;
onFocus: () => void;
hasFocusedPeer: boolean;
} & FilterOption,
)
{
const { ref, focusSelf, focused } = useFocusable({
focusKey: data.id,
onFocus: data.onFocus,
onEnterPress: data.onAction,
onEnterPress: data.onAction
});
const { filter } = useSearch({ from: '/' });
useEffect(() =>
{
if (filter == data.id && data.hasFocusedPeer)
{
focusSelf();
}
}, [filter]);
return (
<li
ref={ref}
@ -46,7 +59,14 @@ export function FilterUI (data: {
setSelected: (id: string) => void;
})
{
const { ref, focusKey } = useFocusable({ focusKey: `filter-${data.id}` });
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: `filter-${data.id}`,
saveLastFocusedChild: false,
autoRestoreFocus: false,
preferredChildFocusKey: data.selected,
trackChildren: true
});
return (
<div
ref={ref}
@ -60,6 +80,7 @@ export function FilterUI (data: {
</li>
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
hasFocusedPeer={hasFocusedChild}
id={id}
key={id}
onFocus={() => data.setSelected(id)}

View file

@ -27,7 +27,7 @@ export interface GameCardParams
id: string;
badges?: JSX.Element[];
className?: string;
onFocus?: (id: string) => void;
onFocus?: (id: string, node: HTMLElement) => void;
onBlur?: (id: string) => void;
onAction?: () => void;
clickFocuses?: boolean;
@ -37,23 +37,11 @@ export default function GameCard (data: GameCardParams)
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id),
onFocus: () => data.onFocus?.(data.id, ref.current as any),
onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id)
});
useEffect(() =>
{
if (focused)
{
(ref.current as HTMLElement).scrollIntoView({
behavior: "smooth",
inline: "center",
block: 'center'
});
}
}, [focused]);
return (
<li
id={`game-entry-${data.id}`}
@ -86,14 +74,14 @@ export default function GameCard (data: GameCardParams)
>
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
{typeof data.preview === "string" ? (
<img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
<img className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
<div className="h-0 flex pr-2 justify-end items-center">
{data.badges?.map(b =>
<div
{data.badges?.map((b, i) =>
<div key={i}
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))}

View file

@ -1,7 +1,7 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { FrontEndId, RPC_URL } from "../../shared/constants";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
@ -20,6 +20,7 @@ export interface GameListParams
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void;
onFocus?: (node: HTMLElement) => void;
className?: string;
}
@ -35,7 +36,6 @@ export function GameList (data: GameListParams)
}).then(d => d.data)
});
const navigator = useNavigate();
const location = useLocation();
const handleFocus = (id: FrontEndId) =>
{
@ -61,6 +61,7 @@ export function GameList (data: GameListParams)
type="game"
grid={data.grid}
className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)}
games={games.data?.games
.map(
(g) =>

View file

@ -18,7 +18,7 @@ export default function LoadingCardList (data: { placeholderCount: number, grid?
}}
style={{ scrollbarWidth: "none" }}
>
{new Array(data.placeholderCount).fill(1).map(p => <GameCardSkeleton />)}
{new Array(data.placeholderCount).fill(1).map((p, i) => <GameCardSkeleton key={i} />)}
</ul>
);
}

View file

@ -0,0 +1,38 @@
import { Notification, RPC_URL } from "@/shared/constants";
import { useEffect } from "react";
import toast from "react-hot-toast";
export default function Notifications (data: {})
{
useEffect(() =>
{
const es = new EventSource(`${RPC_URL(__HOST__)}/api/system/notifications`);
es.addEventListener('notification', (e) =>
{
const notification = JSON.parse(e.data) as Notification;
if (notification.type === 'error')
{
toast.error(notification.message);
} else if (notification.type === 'success')
{
toast.success(notification.message);
} else
{
toast.custom(notification.message);
}
});
es.onerror = (event) =>
{
const error = (event as any).data?.error;
if (error)
{
toast.error(error);
}
};
return () => es.close();
}, []);
return undefined;
}

View file

@ -1,12 +1,12 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
{
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
@ -27,6 +27,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
type="platform"
id={data.id}
className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)}
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g) => ({
id: g.slug,
@ -42,6 +43,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
),
onSelect: () =>
{
SaveSource('game-list');
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
},
preview:

View file

@ -4,6 +4,7 @@ import classNames from "classnames";
import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: {
id: string;
icon: IconType;
label?: string;
className?: string;
@ -11,8 +12,9 @@ export default function ShortcutPrompt (data: {
})
{
return (
<span
<div
onClick={data.onClick}
style={{ viewTransitionName: data.id }}
className={twMerge(
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
"sm:text-sm",
@ -24,6 +26,6 @@ export default function ShortcutPrompt (data: {
>
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} />
{data.label}
</span>
</div>
);
}

View file

@ -1,18 +1,39 @@
import { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
export interface Shortcut
{
icon: IconType;
label: string;
action?: () => void;
}
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
{
return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
<div className="flex gap-2">
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={iconMap[s.button]}
label={s.label} />
)}
</div>
);
}

View file

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './../routes/__root'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index'
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
@ -29,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({
id: '/emulators',
path: '/emulators',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({
id: '/directories',
path: '/directories',
@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
@ -83,6 +90,7 @@ export interface FileRoutesByTo {
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
@ -95,6 +103,7 @@ export interface FileRoutesById {
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
@ -108,6 +117,7 @@ export interface FileRouteTypes {
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
@ -119,6 +129,7 @@ export interface FileRouteTypes {
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
@ -130,6 +141,7 @@ export interface FileRouteTypes {
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
@ -160,6 +172,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/emulators': {
id: '/settings/emulators'
path: '/emulators'
fullPath: '/settings/emulators'
preLoaderRoute: typeof SettingsEmulatorsRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/directories': {
id: '/settings/directories'
path: '/directories'
@ -216,12 +235,14 @@ interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAccountsRoute: typeof SettingsAccountsRoute
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
}
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAboutRoute: SettingsAboutRoute,
SettingsAccountsRoute: SettingsAccountsRoute,
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
}
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(

View file

@ -2,6 +2,8 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterContext } from "..";
import Notifications from "../components/Notifications";
import { Toaster } from "react-hot-toast";
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@ -12,6 +14,8 @@ function RootComponent ()
return (
<div className="w-screen h-screen overflow-hidden">
<Outlet />
<Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
{import.meta.env.DEV &&
<>
<TanStackRouterDevtools position="top-left" />

View file

@ -1,5 +1,5 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEventListener, useSessionStorage } from 'usehooks-ts';
import { createFileRoute } from '@tanstack/react-router';
import { useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../components/CollectionsDetail';
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../shared/constants';
@ -19,8 +19,6 @@ function RouteComponent ()
"home-background",
undefined,
);
const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
return (
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />

View file

@ -16,8 +16,7 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/r
import { Router } from "../..";
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
import Shortcuts from "../../components/Shortcuts";
const placeholderText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eleifend ante magna, id euismod quam tempus sit amet. Maecenas sem lectus, euismod imperdiet volutpat ac, posuere in turpis. Vestibulum commodo lacinia lectus sit amet ultricies. Integer euismod consequat elit, sit amet dapibus libero fermentum nec. Aliquam accumsan placerat dui a maximus. Nunc lectus urna, scelerisque a magna non, imperdiet lobortis turpis. Aliquam magna dui, porttitor in nisl vitae, pretium fringilla sem. ";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
const gameQuery = (source: string, id: number) => queryOptions({
queryKey: ['game', source, id],
@ -50,53 +49,10 @@ function GameDetailsUIPending ()
</AnimatedBackground>;
}
export function GameDetailsUI ()
{
const { source, id } = Route.useParams();
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
useEventListener("cancel", (e) =>
{
e.stopPropagation();
HandleGoBack();
}, ref);
useEffect(() =>
{
if (isSuccess)
{
focusSelf();
}
}, [isSuccess]);
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
<div className="z-0 overflow-y-scroll">
<FocusContext value={focusKey}>
<div className="px-3 py-2" ref={mainAreaRef}>
<HeaderUI />
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: "Play" }]} />
</footer>
</FocusContext>
</div>
</AnimatedBackground>
);
}
function HandleGoBack ()
{
Router.navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
const source = PopSource('details');
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
@ -153,7 +109,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
{data.game?.source ?? data.game?.id.source}
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div>
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
<div className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
@ -162,7 +118,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-[80%]"></div>
</div>}
</p>
</div>
{!!data.game && <ActionButtons key="actions" game={data.game} />}
</div>
</section>
@ -277,6 +233,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
location.reload();
});
es.addEventListener('error', (e) =>
{
if ((e as any).data)
{
const stats = JSON.parse((e as any).data) as GameInstallProgress;
toast.error(stats.error);
}
});
es.onerror = (event) =>
{
const error = (event as any).data?.error;
@ -415,7 +380,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
error: 'bg-error text-error-content'
};
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 h-32 items-center">
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 min-h-32 items-center">
<FocusContext value={focusKey}>
<MainActions game={data.game} />
<AchievementsInfo game={data.game} />
@ -488,3 +453,44 @@ function ActionButton (data: {
</button>
);
}
export default function GameDetailsUI ()
{
const { source, id } = Route.useParams();
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
if (isSuccess)
{
focusSelf();
}
}, [isSuccess]);
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
<div className="z-0 overflow-y-scroll">
<FocusContext value={focusKey}>
<div className="px-3 py-2" ref={mainAreaRef}>
<HeaderUI />
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts shortcuts={shortcuts} />
</footer>
</FocusContext>
</div>
</AnimatedBackground>
);
}

View file

@ -24,7 +24,7 @@ import
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { useEventListener, useLocalStorage } from "usehooks-ts";
import { useEventListener } from "usehooks-ts";
import
{
getCollectionsApiCollectionsGetOptions,
@ -43,10 +43,14 @@ import { twMerge } from "tailwind-merge";
import Shortcuts from "../components/Shortcuts";
import { PlatformsList } from "../components/PlatformsList";
import { systemApi } from "../scripts/clientApi";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import z from "zod";
import { Router } from "..";
import CollectionList from "../components/CollectionList";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
validateSearch: z.object({ filter: z.string().optional().default('games') })
});
const filters = {
@ -61,47 +65,6 @@ const filters = {
},
};
function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; })
{
const navigate = useNavigate();
const { data: collections } = useSuspenseQuery({
...getCollectionsApiCollectionsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime
});
return (
<CardList
type="collection"
id={data.id}
className={data.className}
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
.map((g) => ({
id: String(g.id),
title: g.name,
focusKey: `collection-${g.id}`,
subtitle: g.user__username,
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.rom_count}
</span>
],
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onGameFocus={(id) =>
{
data.setBackground(
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
);
}}
/>
);
}
function HomeListError (data: { focused: boolean; })
{
const error = useErrorBoundary();
@ -112,19 +75,26 @@ function HomeListError (data: { focused: boolean; })
}
function HomeList (data: {
selectedFilter: keyof typeof filters;
selectedFilter: string;
})
{
const [initFocus, setInitFocus] = useState(false);
const bg = useContext(AnimatedBackgroundContext);
const { ref, focused, focusKey, focusSelf } = useFocusable({
focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list`
});
const lists = {
consoles: <PlatformsList className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
games: <GameList className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
const handleNodeFocus = (node: HTMLElement) =>
{
node.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
setInitFocus(true);
};
const lists: Record<string, JSX.Element> = {
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
};
useEventListener('wheel', e =>
@ -169,64 +139,6 @@ function HomeList (data: {
);
}
export default function ConsoleHomeUI ()
{
const [selectedFilter, setSelectedFilter] = useLocalStorage<
keyof typeof filters
>("home-filter-selected", "games");
const closeMutation = useMutation({
mutationKey: ['close'], mutationFn: async () =>
{
const { error } = await systemApi.api.system.exit.post();
if (error) throw error;
}
});
const { ref, focusKey, focusSelf } = useFocusable({
forceFocus: true,
autoRestoreFocus: false,
saveLastFocusedChild: false,
focusKey: "Home",
preferredChildFocusKey: `home-list`,
});
return (
<AnimatedBackground animated ref={ref} backgroundKey="home-background">
<FocusContext.Provider value={focusKey}>
<div className="px-3 w-full pt-2">
<HeaderUI buttons={[
{ id: "search", icon: <Search /> },
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
]} />
</div>
<div className="flex w-full flex-col grow justify-evenly">
<FilterUI
id="home"
options={filters}
selected={selectedFilter}
setSelected={setSelectedFilter as any}
/>
<div className="-mb-1">
<HomeList
selectedFilter={selectedFilter}
/>
</div>
<div>
<MainMenu />
</div>
</div>
<footer className="px-2 pb-2 flex items-center justify-between">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: 'Select' }]} />
</footer>
</FocusContext.Provider>
</AnimatedBackground>
);
}
function MainMenu (data: {})
{
const { ref, focusKey, hasFocusedChild } = useFocusable({
@ -234,7 +146,6 @@ function MainMenu (data: {})
trackChildren: true,
onBlur: (layout, props, details) => { },
});
const location = useLocation();
const navigate = useNavigate();
return (
<ul
@ -278,10 +189,11 @@ function CircleIcon (data: {
icon?: JSX.Element;
})
{
const { ref, focused } = useFocusable({
const { ref, focused, focusKey } = useFocusable({
focusKey: `navigation-icon-${data.label}`,
onEnterPress: data.action,
});
useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]);
const typeClasses = {
secondary: "bg-secondary text-secondary-content",
accent: "bg-accent text-accent-content",
@ -305,3 +217,84 @@ function CircleIcon (data: {
</li>
);
}
export default function ConsoleHomeUI ()
{
const { filter } = Route.useSearch();
const closeMutation = useMutation({
mutationKey: ['close'], mutationFn: async () =>
{
const { error } = await systemApi.api.system.exit.post();
if (error) throw error;
}
});
const { ref, focusKey, focusSelf } = useFocusable({
forceFocus: true,
autoRestoreFocus: false,
saveLastFocusedChild: false,
focusKey: "Home",
preferredChildFocusKey: `home-list`,
});
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } });
useShortcuts(focusKey, () => [
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
},
button: GamePadButtonCode.R1
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
},
button: GamePadButtonCode.L1
}], [filter]);
const { shortcuts } = useShortcutContext();
return (
<AnimatedBackground animated ref={ref} backgroundKey="home-background">
<FocusContext.Provider value={focusKey}>
<div className="px-3 w-full pt-2">
<HeaderUI buttons={[
{ id: "search", icon: <Search /> },
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
]} />
</div>
<div className="flex w-full flex-col grow justify-evenly">
<FilterUI
id="home"
options={filters}
selected={filter ? filter : 'games'}
setSelected={setFilter}
/>
<div className="-mb-1">
<HomeList
selectedFilter={filter}
/>
</div>
<div>
<MainMenu />
</div>
</div>
<footer className="px-2 pb-2 flex items-center justify-between h-12">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts shortcuts={shortcuts} />
</footer>
</FocusContext.Provider>
</AnimatedBackground>
);
}

View file

@ -1,6 +1,7 @@
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
component: RouteComponent,
@ -50,6 +51,10 @@ function RouteComponent ()
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Space</th>
<td>{!!systemInfo?.data && `${prettyBytes(systemInfo?.data?.freeSpace)} Free / ${prettyBytes(systemInfo?.data?.totalSpace)} Total | ${(1 - (systemInfo?.data?.freeSpace / systemInfo?.data?.totalSpace)).toLocaleString('en-GB', { style: "percent" })}`}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>

View file

@ -1,224 +1,17 @@
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute } from '@tanstack/react-router';
import { SettingsOption } from '../../components/options/SettingsOption';
import { OptionSpace } from '../../components/options/OptionSpace';
import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query';
import { settingsApi } from '../../scripts/clientApi';
import { useCallback, useState } from 'react';
import { Button } from '../../components/options/Button';
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
import { RPC_URL } from '../../../shared/constants';
import emulators from '@emulators';
export const Route = createFileRoute('/settings/directories')({
component: RouteComponent,
pendingComponent: EmulatorsPending,
});
function EmulatorsPending ()
{
return <div className="flex flex-col p-2 px-3 w-full h-full">
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
</div>;
}
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
return <ul className='flex gap-1' ref={ref}>
<FocusContext value={focusKey}>
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
)}
</FocusContext>
</ul>;
}
function EmulatorListType (data: { category: string, action: (e: string) => void, })
{
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
return <div ref={ref} className='grow'>
<FocusContext value={focusKey}>
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
id: e,
action: (ctx) =>
{
data.action(e);
ctx.close();
},
type: 'primary',
content: e
} satisfies DialogEntry))} />
</FocusContext>
</div>;
}
function NewEmulatorPath (data: {})
{
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
const handleCloseContext = () =>
{
setNewEmulatorTypeOpen(false);
setFocus('emulator');
};
const addOverrideMutation = useMutation({
mutationKey: ['emulator', 'custom', 'add'],
mutationFn: async (id: string) =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
if (error) throw error;
return data;
},
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
});
return <OptionSpace label={"Custom Emulator Path"}>
<Button disabled={addOverrideMutation.isPending} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator
<ChevronDown />
</Button>
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
<div className='flex flex-col'>
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
<div className="divider mb-1 mt-2"></div>
<EmulatorListType category={newEmulatorContextCat} action={e =>
{
addOverrideMutation.mutate(e);
}} />
</div>
</ContextDialog>
</OptionSpace>;
}
function EmulatorPath (data: { id: string; })
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
const { data: remoteValue } = useQuery({
enabled: !!data.id,
queryKey: ["emulator", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
if (error) throw error;
return value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["emulator", data.id, 'set'],
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const deleteMutation = useMutation({
mutationKey: ["emulator", data.id, 'delete'],
mutationFn: async () =>
{
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
if (error) throw error;
},
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue ?? '');
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
<div className='flex gap-2'>
<OptionInput
name={data.id ?? ""}
type="text"
onBlur={handleSave}
autocomplete="off"
defaultValue={remoteValue}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
<Trash />
</Button>
</div>
</OptionSpace>
);
}
function EmulatorBadge (data: { path?: string, exists: boolean, emulator: string; pathCover?: string; })
{
const { ref, focused } = useFocusable({
focusKey: `badge-${data.emulator}`, onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
<div ref={ref} className={
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
classNames({
"bg-base-200/50": !data.path,
"border-dashed border-base-content/40 border-2": focused
}))
}>
<p className='flex gap-2 font-semibold'>
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
{data.emulator}
</p>
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
</div>
</div>;
}
function EmulatorBadges (data: { path?: string; })
{
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.data?.map(e => <EmulatorBadge pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
</FocusContext>
</div>;
}
function RouteComponent ()
{
const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({
preferredChildFocusKey: focus
});
const { data: customEmulators } = useQuery({
queryKey: ['custom-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
if (error) throw error;
return data;
}
});
return <FocusContext value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">
@ -228,15 +21,6 @@ function RouteComponent ()
</div>
</div>
<SettingsOption label="Download Path" id="downloadPath" type="text" />
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Emulatos</h3>
</div>
</div>
<EmulatorBadges />
<div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath />
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
</ul>
</FocusContext>;
}

View file

@ -0,0 +1,246 @@
import { createFileRoute } from '@tanstack/react-router';
import { OptionSpace } from '../../components/options/OptionSpace';
import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query';
import { settingsApi } from '../../scripts/clientApi';
import { useCallback, useState } from 'react';
import { Button } from '../../components/options/Button';
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
import { RPC_URL } from '../../../shared/constants';
import emulators from '@emulators';
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
pendingComponent: EmulatorsPending,
});
function EmulatorsPending ()
{
return <div className="flex flex-col p-2 px-3 w-full h-full">
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
</div>;
}
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
return <ul className='flex gap-1' ref={ref}>
<FocusContext value={focusKey}>
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
)}
</FocusContext>
</ul>;
}
function EmulatorListType (data: { category: string, action: (e: string) => void, })
{
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
return <div ref={ref} className='grow'>
<FocusContext value={focusKey}>
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
id: e,
action: (ctx) =>
{
data.action(e);
ctx.close();
},
type: 'primary',
content: e
} satisfies DialogEntry))} />
</FocusContext>
</div>;
}
function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAddingOverride: boolean; })
{
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
const handleCloseContext = () =>
{
setNewEmulatorTypeOpen(false);
setFocus('emulator');
};
return <OptionSpace label={"Custom Emulator Path"}>
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator
<ChevronDown />
</Button>
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
<div className='flex flex-col'>
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
<div className="divider mb-1 mt-2"></div>
<EmulatorListType category={newEmulatorContextCat} action={e =>
{
data.addOverride(e);
}} />
</div>
</ContextDialog>
</OptionSpace>;
}
function EmulatorPath (data: { id: string; })
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
const { data: remoteValue } = useQuery({
enabled: !!data.id,
queryKey: ["emulator", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
if (error) throw error;
return value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["emulator", data.id, 'set'],
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const deleteMutation = useMutation({
mutationKey: ["emulator", data.id, 'delete'],
mutationFn: async () =>
{
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
if (error) throw error;
},
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue ?? '');
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
<div className='flex gap-2'>
<OptionInput
name={data.id ?? ""}
type="text"
onBlur={handleSave}
autocomplete="off"
defaultValue={remoteValue}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
<Trash />
</Button>
</div>
</OptionSpace>
);
}
function EmulatorBadge (data: {
path?: string,
exists: boolean,
emulator: string;
pathCover?: string;
addOverride: (emulator: string) => void;
})
{
const { focusKey, ref, focused } = useFocusable({
focusKey: `badge-${data.emulator}`, onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
useShortcuts(focusKey, () => [{
label: 'Add Override', button: GamePadButtonCode.A, action: () =>
data.addOverride(data.emulator)
}], [data.addOverride]);
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
<div ref={ref} className={
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
classNames({
"bg-base-200/50": !data.path,
"border-dashed border-base-content/40 border-2": focused
}))
}>
<p className='flex gap-2 font-semibold'>
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
{data.emulator}
</p>
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
</div>
</div>;
}
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
{
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
</FocusContext>
</div>;
}
function RouteComponent ()
{
const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({
preferredChildFocusKey: focus
});
const { data: customEmulators } = useQuery({
queryKey: ['custom-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
if (error) throw error;
return data;
}
});
const addOverrideMutation = useMutation({
mutationKey: ['emulator', 'custom', 'add'],
mutationFn: async (id: string) =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
if (error) throw error;
return data;
},
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
});
return <FocusContext value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">
<EmulatorBadges addOverride={addOverrideMutation.mutate} />
<div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
</ul>
</FocusContext>;
}

View file

@ -6,12 +6,11 @@ import
import
{
Outlet,
Link,
createFileRoute,
useMatchRoute,
useNavigate,
} from "@tanstack/react-router";
import { retainSearchParams, ViewTransitionOptions } from "@tanstack/router-core";
import { ViewTransitionOptions } from "@tanstack/router-core";
import classNames from "classnames";
import
{
@ -19,16 +18,17 @@ import
FingerprintPattern,
HardDrive,
Info,
Joystick,
MonitorCog,
} from "lucide-react";
import { JSX, useEffect, useRef } from "react";
import { useEventListener } from "usehooks-ts";
import ShortcutPrompt from "../../components/ShortcutPrompt";
import { JSX, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import z from "zod";
import { SettingsSchema } from "../../../shared/constants";
import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
@ -123,6 +123,12 @@ function SettingsMenu (data: {})
label="Visual"
icon={<MonitorCog />}
/>
<MenuItem
focusSelect
route="/settings/emulators"
label="Emulators"
icon={<Joystick />}
/>
<MenuItem
focusSelect
route="/settings/directories"
@ -172,12 +178,14 @@ export function SettingsUI ()
preferredChildFocusKey: 'settings-menu'
});
useEventListener("cancel", HandleGoBack, ref);
useEffect(() =>
{
focusSelf();
}, []);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
return (
<FocusContext.Provider value={focusKey}>
<div ref={ref} className="flex flex-col w-full h-full p-4 bg-base-100">
@ -191,11 +199,7 @@ export function SettingsUI ()
</div>
</div>
<div className="divider divider-end">
<ShortcutPrompt
onClick={HandleGoBack}
icon="steamdeck_button_b"
label="Back"
/>
<Shortcuts shortcuts={shortcuts} />
</div>
</div>
</FocusContext.Provider>

View file

@ -3,6 +3,7 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
let loopStarted = false;
window.addEventListener("gamepadconnected", (evt) =>
{
if (!loopStarted)
@ -11,6 +12,7 @@ window.addEventListener("gamepadconnected", (evt) =>
loopStarted = true;
}
});
window.addEventListener("gamepaddisconnected", (evt) =>
{
@ -45,7 +47,20 @@ window.addEventListener('keydown', e =>
}
});
export class GamepadButtonEvent extends Event
{
button: number;
gamepad?: Gamepad;
isClick: boolean;
constructor(type: string, init: EventInit & { button: number, gamepad?: Gamepad; isClick?: boolean; })
{
super(type, init);
this.button = init.button;
this.gamepad = init.gamepad;
this.isClick = init.isClick ?? false;
}
}
function updateStatus ()
{
@ -53,32 +68,25 @@ function updateStatus ()
{
const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, });
if (gamepad.buttons[0].pressed)
for (let i = 0; i < gamepad.buttons.length; i++)
{
if (!throttleMap.has('enter'))
{
dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }), window);
throttleMap.set('enter', 0);
}
} else
{
if (throttleMap.delete('enter'))
{
dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
}
}
const button = gamepad.buttons[i];
const key = String(i);
if (gamepad.buttons[1].pressed)
if (button.pressed)
{
if (!throttleMap.has('cancel'))
if (!throttleMap.has(key))
{
const evn = new Event('cancel', { bubbles: true, cancelable: true });
dispatchFocusedEvent(evn);
throttleMap.set('cancel', 0);
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad }));
throttleMap.set(key, 0);
}
} else
{
throttleMap.delete('cancel');
if (throttleMap.delete(key))
{
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad }));
}
}
}
const activeFocus = GetFocusedElement(getCurrentFocusKey());

View file

@ -0,0 +1,137 @@
import { DependencyList, useEffect, useState } from "react";
import { GamepadButtonEvent } from "./gamepads";
import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
const shortcutMap = new Map<string, Shortcut[]>();
const conflictSet = new Set<number>();
let hadEnterDown = false;
export enum GamePadButtonCode
{
A = 0,
B = 1,
X = 2,
Y = 3,
L1 = 4,
R1 = 5,
L2 = 6,
R2 = 7,
Select = 8,
Start = 9,
LJoy = 10,
RJoy = 11,
Up = 12,
Down = 13,
Left = 14,
Right = 15,
Steam = 16
}
export interface Shortcut
{
label?: string;
button: GamePadButtonCode;
action: (e: GamepadButtonEvent) => void;
}
export function useShortcutContext ()
{
const [array, setArray] = useState<Shortcut[] | undefined>();
useEffect(() =>
{
const handleShortcutRebuild = () =>
{
conflictSet.clear();
const newArray = GetFocusedTree(getCurrentFocusKey())
.filter(f => shortcutMap.has(f))
.flatMap(f => shortcutMap.get(f)!)
.filter(s =>
{
const empty = !conflictSet.has(s.button);
conflictSet.add(s.button);
return empty;
});
if (!compareShortcutArrays(newArray, array))
{
setArray(newArray);
}
};
const shortcuts = new Map(array?.reverse().map(s => [s.button, s]) ?? []);
const handleGamepadButtonDown = (e: Event) =>
{
const event = e as GamepadButtonEvent;
if (shortcuts.has(event.button))
{
shortcuts.get(event.button)?.action(event);
}
else if (event.button === GamePadButtonCode.A)
{
dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
hadEnterDown = true;
}
};
const handleGamepadButtonUp = (e: Event) =>
{
const event = e as GamepadButtonEvent;
if (hadEnterDown && event.button === GamePadButtonCode.A)
{
dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
}
};
function compareShortcut (a: Shortcut, b: Shortcut)
{
return a.action === b.action && a.button === b.button && a.label === b.label;
}
function compareShortcutArrays (a: Shortcut[] | undefined, b: Shortcut[] | undefined)
{
if (a === b) return true;
if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++)
{
if (!compareShortcut(a[i], b[i]))
{
return false;
}
}
return true;
}
if (!array)
{
handleShortcutRebuild();
}
window.addEventListener('gamepadbuttondown', handleGamepadButtonDown);
window.addEventListener('gamepadbuttonup', handleGamepadButtonUp);
window.addEventListener('focuschanged', handleShortcutRebuild);
return () =>
{
window.removeEventListener('focuschanged', handleShortcutRebuild);
window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown);
window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp);
};
}, [array]);
return { shortcuts: array };
}
export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList)
{
useEffect(() =>
{
shortcutMap.set(focusKey, build());
return () =>
{
shortcutMap.delete(focusKey);
};
}, [...deps, focusKey]);
}

View file

@ -17,12 +17,17 @@ let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNa
type SaveFocusType = "session" | "local";
type HistorySourceType = "settings" | 'details' | 'launch';
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list';
const historySourceMap = new Map<string, string>();
export function SaveSource (id: HistorySourceType, url?: string)
{
historySourceMap.set(id, url ?? location.hash.replace("#", ''));
const finalUrl = url ?? location.hash.replace("#", '');
if (finalUrl)
{
historySourceMap.set(id, finalUrl);
}
}
export function HasSource (id: HistorySourceType)
@ -46,6 +51,27 @@ export function GetFocusedElement (focusKey: string)
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement;
}
export function GetFocusedTree (leaf: string): string[]
{
const tree: string[] = [];
let component = (SpatialNavigation as any).focusableComponents[leaf];
while (component)
{
tree.push(component.focusKey);
if (component.parentFocusKey && !tree.includes(component.parentFocusKey))
{
component = (SpatialNavigation as any).focusableComponents[component.parentFocusKey];
}
else
{
break;
}
}
return tree;
}
export function dispatchFocusedEvent (event: Event, override?: Element | Window)
{
const focusedElement = GetFocusedElement(getCurrentFocusKey());

View file

@ -79,6 +79,13 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType
};
};
export interface Notification
{
title?: string;
message: string;
type: 'success' | 'error';
}
export type SettingsType = z.infer<typeof SettingsSchema>;
export interface GameInstallProgress
{