diff --git a/.vscode/launch.json b/.vscode/launch.json index 30dd986..59a689f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d8e389..41de9ee 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c6d29c6..aaceda4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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 + } } ] } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 5855171..612e23b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/gameflow-deck.code-workspace b/gameflow-deck.code-workspace new file mode 100644 index 0000000..8c227fa --- /dev/null +++ b/gameflow-deck.code-workspace @@ -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" + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 882de28..1a513dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..6279dd5 --- /dev/null +++ b/scripts/dev.ts @@ -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(); \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 465418e..f64bbea 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -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({ projectName: projectPackage.name, @@ -58,7 +59,14 @@ export function setActiveGame (game: ActiveGame) return activeGame = game; } export const events = new EventEmitter(); -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]; } \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 156faf4..8394d40 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -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,29 +217,89 @@ 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 } }); - const game = setActiveGame({ - process: Bun.spawn({ - cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) + try { - return status('Internal Server Error'); + 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, + 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, + command: validCommand.command.command + }); + + await game.process.exited; + 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)); } - return status('OK'); + + } } }, { diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 4f87e66..dda0f1b 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -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,40 +79,79 @@ 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; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + const staticVars: Record = { + '%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\%=(?[\w\%.\/\\]+)/g, (subscring, injectFile: string) => { - let emulator: string | undefined = undefined; - const varList = await Promise.all(matches.map(async (value) => + try { - if (value.startsWith("%EMULATOR_")) + const resolvedInjectFile = injectFile.replace(varRegex, (a) => { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let exec = await findExec(emulatorName); - if (data.customEmulatorConfig.has(emulatorName)) - { - exec = data.customEmulatorConfig.get(emulatorName); - } - - emulator = emulatorName; - return [value, exec]; + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); } - const key = value.substring(1, value.length - 1); - return [value, process.env[key]]; - })); - const vars = Object.fromEntries(varList); - vars['%ROM%'] = validFiles[0]; - vars['%ESPATH%'] = config.get('downloadPath'); + return ''; + } catch (error) + { + return ''; + } + }); - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); + const matches = Array.from(cmd.matchAll(varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let exec = await findExec(emulatorName); + if (data.customEmulatorConfig.has(emulatorName)) + { + exec = data.customEmulatorConfig.get(emulatorName); + } - const command = cmd.replace(varRegex, (s) => vars[s] ?? ''); - return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry; - } + emulator = emulatorName; + return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]]; + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]]]; + })); + + 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 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; diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 8037c25..ea0a439 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -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,9 +119,19 @@ export default async function buildStatusResponse (source: string, id: number) enqueue({ status: 'installed', details: validCommand.command.label }); } - } else + } else if (source === 'romm') { - enqueue({ status: 'install', details: 'Install' }); + // 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' }); + } + } } } @@ -126,8 +139,15 @@ export default async function buildStatusResponse (source: string, id: number) 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); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 90e0a25..09b04fd 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -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; diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts new file mode 100644 index 0000000..6446747 --- /dev/null +++ b/src/bun/api/notifications.ts @@ -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?.(); + } + }); +} \ No newline at end of file diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index a210382..556a289 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -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 } }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 39b6623..6294009 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -48,12 +48,14 @@ export class TaskQueue public waitForJob (id: string): Promise { - 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 (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void diff --git a/src/bun/browser.ts b/src/bun/browser.ts new file mode 100644 index 0000000..3249a3f --- /dev/null +++ b/src/bun/browser.ts @@ -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); + }); + }); + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index eba7b37..0af3f6d 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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(); + events.on('exitapp', () => + { + process.send?.({ type: 'exitapp' }); + cleanup(); + }); } 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); - }); - }); + await init(events, !!Bun.env.FORCE_BROWSER); 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 - }); - events.on('exitapp', () => browser.kill(15)); - } -} \ No newline at end of file + + diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index d97f781..e5d8f5a 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -1,7 +1,7 @@ declare const IS_BINARY: string; export type ActiveGame = { - process: Bun.Subprocess; + pid?: number; gameId: number; name: string; command: string; diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 0a4c0ce..2f38abe 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -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')}`); diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 5f59d12..673c3c4 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -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 { - // Configuration for both Flatpak and Native // Contains Flatpak app IDs, internal container paths, and fallback binary names const config: Record = { @@ -91,7 +99,7 @@ export function spawnBrowser ({ const target = config[browser]; const useFlatpak = source === "flatpak"; - + let cmd: string[]; let finalEnv: Record | undefined; @@ -100,9 +108,9 @@ export function spawnBrowser ({ // --- Flatpak Mode (Steam Style) --- // Structure: flatpak run [ENV] [FLATPAK_OPTS] [APP_ID] @@u @@ [USER_ARGS] // The @@u @@ syntax enables file forwarding for URL arguments - + const envFlags = Object.entries(env).map(([k, v]) => `--env=${k}=${v}`); - + // We explicitly set the command to ensure we don't rely on the default entrypoint failing const flatpakOpts = [ "run", @@ -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({ diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 39f9618..03232b0 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -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 ( + 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" }} > - {data.games.map(BuildGame)} + {data.games.map(BuildCard)} ); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx new file mode 100644 index 0000000..47edc0a --- /dev/null +++ b/src/mainview/components/CollectionList.tsx @@ -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 ( + 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: [ + + {g.rom_count} + + ], + } 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); + }} + /> + ); +} \ No newline at end of file diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 095ed68..655d36b 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -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 ( @@ -44,7 +60,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
{data.footer}
- +
diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 6e13528..c623d43 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -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) => - { - if (data.open) + useShortcuts(focusKey, () => [{ + label: "Close", + button: GamePadButtonCode.B, + action: () => { - e.stopPropagation(); data.close(); } - }, ref); + }], []); return 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 (
  • 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 (
    {Object.entries(data.options)?.map(([id, option]) => ( data.setSelected(id)} diff --git a/src/mainview/components/GameCard.tsx b/src/mainview/components/GameCard.tsx index 5085f2a..e35ba9f 100644 --- a/src/mainview/components/GameCard.tsx +++ b/src/mainview/components/GameCard.tsx @@ -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 (
  • {typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )}
    - {data.badges?.map(b => -
    +
    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) => diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index eba404f..f381f70 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -18,7 +18,7 @@ export default function LoadingCardList (data: { placeholderCount: number, grid? }} style={{ scrollbarWidth: "none" }} > - {new Array(data.placeholderCount).fill(1).map(p => )} + {new Array(data.placeholderCount).fill(1).map((p, i) => )} ); } diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx new file mode 100644 index 0000000..7f17a8b --- /dev/null +++ b/src/mainview/components/Notifications.tsx @@ -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; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 2490264..40f16f9 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -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: diff --git a/src/mainview/components/ShortcutPrompt.tsx b/src/mainview/components/ShortcutPrompt.tsx index 8c7bedf..6ce7eb9 100644 --- a/src/mainview/components/ShortcutPrompt.tsx +++ b/src/mainview/components/ShortcutPrompt.tsx @@ -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 ( - {data.label} - +
    ); } diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index 15bd156..0e35066 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -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.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 ( -
    - {data.shortcuts.map((s, i) => )} +
    + {data.shortcuts?.filter(s => !!s.label).map((s, i) => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={iconMap[s.button]} + label={s.label} /> + )}
    ); } diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index eb2e05c..5ce734a 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -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( diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 4aafc88..e6ac8f0 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -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()({ component: RootComponent, @@ -12,6 +14,8 @@ function RootComponent () return (
    + + {import.meta.env.DEV && <> diff --git a/src/mainview/routes/collection.$id.tsx b/src/mainview/routes/collection.$id.tsx index fd440b5..7f0795d 100644 --- a/src/mainview/routes/collection.$id.tsx +++ b/src/mainview/routes/collection.$id.tsx @@ -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 ( diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 0a5b052..caa27c0 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -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 () ; } -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(null); - - useEventListener("cancel", (e) => - { - e.stopPropagation(); - HandleGoBack(); - }, ref); - - useEffect(() => - { - if (isSuccess) - { - focusSelf(); - } - - }, [isSuccess]); - - return ( - -
    - -
    - -
    -
    -
    Screenshots
    - {!!data && } -
    -
    -
    - -
    -
    -
    -
    - ); -} - 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, game?: FrontEndGameTypeDetailed; }) @@ -153,7 +109,7 @@ function Details (data: { mainAreaRef: RefObject, game?: {data.game?.source ?? data.game?.id.source} {data.game?.local && local}
    -

    +

    {data.game?.summary ??
    @@ -162,7 +118,7 @@ function Details (data: { mainAreaRef: RefObject, game?:
    } -

    +
    {!!data.game && }
    @@ -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
    + return
    @@ -487,4 +452,45 @@ function ActionButton (data: { {data.children} ); +} + +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(null); + + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + const { shortcuts } = useShortcutContext(); + + useEffect(() => + { + if (isSuccess) + { + focusSelf(); + } + + }, [isSuccess]); + + return ( + +
    + +
    + +
    +
    +
    Screenshots
    + {!!data && } +
    +
    +
    + +
    +
    +
    +
    + ); } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 79c6131..34522ea 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -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 ( - 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: [ - - {g.rom_count} - - ], - } 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: , - games: , - collections: , + const handleNodeFocus = (node: HTMLElement) => + { + node.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' }); + setInitFocus(true); + }; + + const lists: Record = { + consoles: , + games: , + collections: , }; 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 ( - - -
    - }, - { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() } - ]} /> -
    -
    - -
    - -
    -
    - -
    -
    - -
    -
    -
    - -
    -
    -
    - ); -} - 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 (
      [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]); const typeClasses = { secondary: "bg-secondary text-secondary-content", accent: "bg-accent text-accent-content", @@ -304,4 +216,85 @@ function CircleIcon (data: { {data.icon} ); +} + +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 ( + + +
      + }, + { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() } + ]} /> +
      +
      + +
      + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      +
      + ); } \ No newline at end of file diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index d35cc86..9b465dd 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -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 () Machine {systemInfo?.data?.machine} + + Space + {!!systemInfo?.data && `${prettyBytes(systemInfo?.data?.freeSpace)} Free / ${prettyBytes(systemInfo?.data?.totalSpace)} Total | ${(1 - (systemInfo?.data?.freeSpace / systemInfo?.data?.totalSpace)).toLocaleString('en-GB', { style: "percent" })}`} + Steam Deck {systemInfo?.data?.steamDeck ?? 'false'} diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 7433309..e9e85fd 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -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
      -
      - -
      -
      ; -} - -function EmulatorListCat (data: { selected: string, set: (c: string) => void; }) -{ - const { ref, focusKey } = useFocusable({ focusKey: 'categories' }); - return
        - - {[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c => - data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" /> - )} - -
      ; -} - -function EmulatorListType (data: { category: string, action: (e: string) => void, }) -{ - const { ref, focusKey } = useFocusable({ focusKey: 'list-section' }); - return
      - - e.startsWith(data.category)).map(e => ({ - id: e, - action: (ctx) => - { - data.action(e); - ctx.close(); - }, - type: 'primary', - content: e - } satisfies DialogEntry))} /> - -
      ; -} - -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 - - -
      - -
      - - { - addOverrideMutation.mutate(e); - }} /> -
      -
      -
      ; -} - -function EmulatorPath (data: { id: string; }) -{ - const [dirty, setDirty] = useState(false); - const [localValue, setLocalValue] = useState(); - 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 ( -

      {data.id}

      {emulators[data.id]}}> -
      - - { - setLocalValue(e.currentTarget.value); - setDirty(true); - }} - value={localValue} - /> - -
      -
      - ); -} - -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
      -
      -

      - {data.path ? data.exists ? : : } - {!!data.pathCover && } - {data.emulator} -

      - {data.path ? {data.path} : ""} -
      -
      ; -} - -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
      - - {autoEmulators?.data?.map(e => )} - -
      ; -} - 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
        @@ -228,15 +21,6 @@ function RouteComponent ()
    -
    -
    -

    Emulatos

    -
    -
    - -
    Overrides
    - - {!!customEmulators && customEmulators.map((key) => )} ; } diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx new file mode 100644 index 0000000..4e0133c --- /dev/null +++ b/src/mainview/routes/settings/emulators.tsx @@ -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
    +
    + +
    +
    ; +} + +function EmulatorListCat (data: { selected: string, set: (c: string) => void; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'categories' }); + return
      + + {[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c => + data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" /> + )} + +
    ; +} + +function EmulatorListType (data: { category: string, action: (e: string) => void, }) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'list-section' }); + return
    + + e.startsWith(data.category)).map(e => ({ + id: e, + action: (ctx) => + { + data.action(e); + ctx.close(); + }, + type: 'primary', + content: e + } satisfies DialogEntry))} /> + +
    ; +} + +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 + + +
    + +
    + + { + data.addOverride(e); + }} /> +
    +
    +
    ; +} + +function EmulatorPath (data: { id: string; }) +{ + const [dirty, setDirty] = useState(false); + const [localValue, setLocalValue] = useState(); + 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 ( +

    {data.id}

    {emulators[data.id]}}> +
    + + { + setLocalValue(e.currentTarget.value); + setDirty(true); + }} + value={localValue} + /> + +
    +
    + ); +} + +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
    +
    +

    + {data.path ? data.exists ? : : } + {!!data.pathCover && } + {data.emulator} +

    + {data.path ? {data.path} : ""} +
    +
    ; +} + +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
    + + {autoEmulators?.data?.map(e => )} + +
    ; +} + +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 +
      + +
      Overrides
      + + {!!customEmulators && customEmulators.map((key) => )} +
    +
    ; +} diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index fcb3810..5e40ec3 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -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={} /> + } + /> { focusSelf(); }, []); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + const { shortcuts } = useShortcutContext(); + return (
    @@ -191,11 +199,7 @@ export function SettingsUI ()
    - +
    diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 21ada8c..c0cf2df 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -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 (!throttleMap.has('cancel')) + if (button.pressed) { - const evn = new Event('cancel', { bubbles: true, cancelable: true }); - dispatchFocusedEvent(evn); - throttleMap.set('cancel', 0); + if (!throttleMap.has(key)) + { + window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad })); + throttleMap.set(key, 0); + } + } else + { + if (throttleMap.delete(key)) + { + window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad })); + } } - } else - { - throttleMap.delete('cancel'); } const activeFocus = GetFocusedElement(getCurrentFocusKey()); diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts new file mode 100644 index 0000000..981bd9b --- /dev/null +++ b/src/mainview/scripts/shortcuts.ts @@ -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(); +const conflictSet = new Set(); +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(); + + 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]); + +} \ No newline at end of file diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index acc6463..37c096f 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -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(); 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()); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ea14198..1d86ea1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -79,6 +79,13 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType }; }; +export interface Notification +{ + title?: string; + message: string; + type: 'success' | 'error'; +} + export type SettingsType = z.infer; export interface GameInstallProgress {