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 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Attach to Edge",
"port": 9222,
"request": "attach",
"type": "msedge",
"webRoot": "${workspaceFolder}/src",
},
{ {
"type": "bun", "type": "bun",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
"request": "attach", "request": "attach",
"name": "Attach Bun", "name": "Attach Bun",
"url": "ws://127.0.0.1:9229/54esztvxlfe", "url": "ws://127.0.0.1:9229/fixed-session",
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
"stopOnEntry": false, "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": [ "compounds": [
{ {
"name": "Attach Debug App", "name": "Attach Debug App",
"configurations": [ "configurations": [
"Attach Bun", "Attach Bun"
"Attach to Edge"
], ],
"stopAll": true, "stopAll": true,
"preLaunchTask": "bun: dev" "preLaunchTask": "bun: dev"

View file

@ -29,5 +29,14 @@
"norigin", "norigin",
"noriginmedia", "noriginmedia",
"romm" "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", "label": "Run Act",
"type": "shell", "type": "shell",
"command": "act", "command": "act",
"args": ["--artifact-server-path", "artifacts", "-W", ".github/workflows/build.yml"], "args": [
"--artifact-server-path",
"artifacts",
"-W",
".github/workflows/build.yml"
],
"options": { "options": {
"env": { "env": {
"PATH": "${env:PATH}", "PATH": "${env:PATH}",
@ -13,6 +18,36 @@
}, },
}, },
"problemMatcher": [] "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", "pathe": "^2.0.3",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
@ -37,6 +38,7 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.9", "@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"babel-plugin-react-compiler": "^1.0.0", "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/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=="], "@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=="], "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=="], "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=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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", "description": "Game Launcher",
"type": "module", "type": "module",
"scripts": { "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'", "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run dev'",
"build": "vite build", "build": "vite build",
"build:pro": "NODE_ENV=production bun run build", "build:pro": "NODE_ENV=production bun run build",
@ -37,6 +37,7 @@
"pathe": "^2.0.3", "pathe": "^2.0.3",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@ -53,6 +54,7 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.9", "@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/unzip-stream": "^0.3.4",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"babel-plugin-react-compiler": "^1.0.0", "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 { drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf"; import Conf from "conf";
import projectPackage from '~/package.json'; 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 { client } from "@clients/romm/client.gen";
import * as schema from "./schema/app"; import * as schema from "./schema/app";
import * as emulatorSchema from "./schema/emulators"; import * as emulatorSchema from "./schema/emulators";
@ -18,6 +18,7 @@ import os from 'node:os';
import { ActiveGame } from "../types/types"; import { ActiveGame } from "../types/types";
import EventEmitter from "node:events"; import EventEmitter from "node:events";
import { ErrorLike } from "bun"; import { ErrorLike } from "bun";
import { getErrorMessage } from "../utils";
export const config = new Conf<SettingsType>({ export const config = new Conf<SettingsType>({
projectName: projectPackage.name, projectName: projectPackage.name,
@ -58,7 +59,14 @@ export function setActiveGame (game: ActiveGame)
return activeGame = game; return activeGame = game;
} }
export const events = new EventEmitter<AppEventMap>(); 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"); console.log("Logging In to Romm");
export async function cleanup () export async function cleanup ()
@ -71,6 +79,7 @@ export async function cleanup ()
interface AppEventMap 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: []; exitapp: [];
notification: [Notification];
} }

View file

@ -5,12 +5,14 @@ import z from "zod";
import * as schema from "../schema/app"; import * as schema from "../schema/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants"; 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 { InstallJob } from "../jobs/install-job";
import path from "node:path"; import path from "node:path";
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils"; import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler"; import { errorToResponse } from "elysia/adapter/bun/handler";
import { getErrorMessage } from "@/bun/utils";
import { spawn } from "node:child_process";
export default new Elysia() export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, set }) => .get('/game/local/:id/cover', async ({ params: { id }, set }) =>
@ -215,17 +217,70 @@ export default new Elysia()
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
where: eq(schema.games.id, validCommand.gameId), columns: { 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({ const game = setActiveGame({
process: Bun.spawn({ 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 }); events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
}, },
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}), }),
name: localGame?.name ?? "Unknown", name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId, gameId: validCommand.gameId,
@ -236,8 +291,15 @@ export default new Elysia()
if (game.process.exitCode && game.process.exitCode > 0) if (game.process.exitCode && game.process.exitCode > 0)
{ {
return status('Internal Server Error'); return status('Internal Server Error');
} }*/
return status('OK'); 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 { which } from 'bun';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import * as schema from '../../schema/emulators'; import * as schema from '../../schema/emulators';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { config, emulatorsDb } from '../../app'; import { config, emulatorsDb } from '../../app';
import os from 'node:os'; import os from 'node:os';
import { $ } from 'bun';
export const varRegex = /%([^%]+)%/g; export const varRegex = /%([^%]+)%/g;
@ -78,13 +79,47 @@ export async function getValidLaunchCommands (data: {
const formattedCommands = await Promise.all(system.commands.map(async command => const formattedCommands = await Promise.all(system.commands.map(async command =>
{ {
const label = command.label; 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 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_")) if (value.startsWith("%EMULATOR_"))
{ {
@ -96,22 +131,27 @@ export async function getValidLaunchCommands (data: {
} }
emulator = emulatorName; 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); const key = value[0].substring(1, value.length - 1);
return [value, process.env[key]]; return [[value, process.env[key]]];
})); }));
const vars = Object.fromEntries(varList);
vars['%ROM%'] = validFiles[0]; const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESPATH%'] = config.get('downloadPath'); vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable // missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined); const invalid = Object.entries(vars).find(c => c[1] === undefined);
const command = cmd.replace(varRegex, (s) => vars[s] ?? ''); const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
} return {
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
})); }));
return formattedCommands.filter(c => !!c); return formattedCommands.filter(c => !!c);
@ -165,8 +205,8 @@ export async function findExec (emulatorName: string)
async function readRegistryValue (text: string) async function readRegistryValue (text: string)
{ {
const params = text.split('|'); const params = text.split('|');
const key = dirname(params[0]); const key = path.dirname(params[0]);
const value = basename(params[0]); const value = path.basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined; const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({ const proc = Bun.spawn({
@ -197,9 +237,10 @@ async function resolveStaticPath (entries: string[])
{ {
for (const entry of entries) 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; return null;

View file

@ -1,10 +1,13 @@
import { GameInstallProgress, GameStatusType, } from "@shared/constants"; 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 { getValidLaunchCommands } from "./launchGameService";
import * as schema from '../../schema/app'; import * as schema from '../../schema/app';
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils"; import { getErrorMessage } from "@/bun/utils";
import { getLocalGameMatch } from "./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 class CommandSearchError extends Error
{ {
@ -116,18 +119,35 @@ export default async function buildStatusResponse (source: string, id: number)
enqueue({ status: 'installed', details: validCommand.command.label }); 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 } else
{ {
enqueue({ status: 'install', details: 'Install' }); enqueue({ status: 'install', details: 'Install' });
} }
}
} }
} }
await sendLatests(); await sendLatests();
const dispose: Function[] = []; const dispose: Function[] = [];
const handleActiveExit = async () => const handleActiveExit = async (data: { error?: ErrorLike; }) =>
{ {
if (data.error)
{
enqueue({
status: 'error',
error: data.error
}, 'error');
}
await sendLatests(); await sendLatests();
}; };
events.on('activegameexit', handleActiveExit); events.on('activegameexit', handleActiveExit);

View file

@ -1,14 +1,17 @@
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "../task-queue";
import { mkdir } from 'node:fs/promises'; 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 fs from 'node:fs/promises';
import { DownloaderHelper } from 'node-downloader-helper'; import { DownloaderHelper } from 'node-downloader-helper';
import StreamZip from 'node-stream-zip'; import StreamZip from 'node-stream-zip';
import * as schema from "../schema/app"; import * as schema from "../schema/app";
import * as emulatorSchema from "../schema/emulators"; import * as emulatorSchema from "../schema/emulators";
import path from 'node:path'; 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 { config, db, emulatorsDb, jar } from "../app";
import unzip from 'unzip-stream';
import { Readable, Transform } from "node:stream";
import { createWriteStream } from "node:fs";
interface JobConfig interface JobConfig
{ {
@ -39,6 +42,7 @@ export class InstallJob implements IJob
if (this.config?.dryDownload !== true) if (this.config?.dryDownload !== true)
{ {
/*
// download files for rom // download files for rom
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`); const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id)); downloadUrl.searchParams.set('rom_ids', String(this.id));
@ -84,7 +88,38 @@ export class InstallJob implements IJob
await zip.extract(null, downloadPath); await zip.extract(null, downloadPath);
await zip.close(); 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; 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)); if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
const esPlatform = await emulatorsDb const esPlatform = await emulatorsDb
.select({ slug: emulatorSchema.systems.name, romm_slug: emulatorSchema.systemMappings.sourceSlug }) .select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
.from(emulatorSchema.systems) .from(emulatorSchema.systemMappings)
.leftJoin(emulatorSchema.systemMappings, eq(emulatorSchema.systemMappings.source, 'romm')) .where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
.where(eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug));
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number; 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 open from 'open';
import z from "zod"; import z from "zod";
import os from 'node:os'; import os from 'node:os';
import { events } from "./app"; import { config, events } from "./app";
import { isSteamDeckGameMode } from "../utils"; 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 // steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
export const system = new Elysia({ prefix: '/api/system' }) export const system = new Elysia({ prefix: '/api/system' })
@ -14,8 +16,11 @@ export const system = new Elysia({ prefix: '/api/system' })
open('steam://open/keyboard'); open('steam://open/keyboard');
} }
}) })
.get('/info', () => .get('/info', async () =>
{ {
const downloadStats = await fs.statfs(config.get('downloadPath'));
return { return {
homeDir: os.homedir(), homeDir: os.homedir(),
user: os.userInfo().username, user: os.userInfo().username,
@ -23,16 +28,21 @@ export const system = new Elysia({ prefix: '/api/system' })
platform: os.platform(), platform: os.platform(),
hostname: os.hostname(), hostname: os.hostname(),
steamDeck: process.env.SteamDeck, 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', () => .post('/exit', () =>
{ {
if (process.env.PUBLIC_ACCESS)
{
return;
}
events.emit('exitapp'); events.emit('exitapp');
}) })
.post('/open', async ({ query: { url } }) => .post('/open', async ({ query: { url } }) =>

View file

@ -48,12 +48,14 @@ export class TaskQueue
public waitForJob (id: string): Promise<void> 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 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 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 { RunBunServer } from './server';
import { RunAPIServer } from './api/rpc'; 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 { cleanup as appCleanup, events } from './api/app';
import os from 'node:os'; import init from './browser';
const api = RunAPIServer(); const api = RunAPIServer();
let bunServer: { stop: () => void; url: URL; } | undefined; let bunServer: { stop: () => void; url: URL; } | undefined;
@ -15,6 +13,7 @@ if (!Bun.env.PUBLIC_ACCESS)
async function cleanup () async function cleanup ()
{ {
console.log("Cleaning Up");
await appCleanup(); await appCleanup();
bunServer?.stop(); bunServer?.stop();
await api.apiServer.stop(); await api.apiServer.stop();
@ -22,73 +21,19 @@ async function cleanup ()
process.exit(0); 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', () => 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 } else
{ {
const browser = spawnBrowser({ await init(events, !!Bun.env.FORCE_BROWSER);
browser: browserParams.browser.type, await cleanup();
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));
}
}

View file

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

View file

@ -59,6 +59,12 @@ export async function BuildParams ()
args.push('--disabled-features=WindowControlsOverlay,navigationControls,Translate,msUndersideButton'); args.push('--disabled-features=WindowControlsOverlay,navigationControls,Translate,msUndersideButton');
args.push(`--profile-directory=Default`); 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')) if (config.has('windowPosition'))
{ {
args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`); 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 RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
export type RunBrowserSource = "running" | "system" | "flatpak"; export type RunBrowserSource = "running" | "system" | "flatpak";
@ -25,6 +29,11 @@ interface SpawnBrowserOptions
ipc?: (message: string) => void; ipc?: (message: string) => void;
} }
interface SpawnLastInfo
{
PID: number;
}
/** /**
* Spawns a browser process with proper handling for different installation types. * 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, browser,
args = [], args = [],
env = {}, env = {},
@ -61,9 +70,8 @@ export function spawnBrowser ({
source, source,
onExit, onExit,
ipc ipc
}: SpawnBrowserOptions): Subprocess }: SpawnBrowserOptions): Promise<Subprocess>
{ {
// Configuration for both Flatpak and Native // Configuration for both Flatpak and Native
// Contains Flatpak app IDs, internal container paths, and fallback binary names // Contains Flatpak app IDs, internal container paths, and fallback binary names
const config: Record<RunBrowserType, { id: string; internalCmd: string; bin: string[]; }> = { const config: Record<RunBrowserType, { id: string; internalCmd: string; bin: string[]; }> = {
@ -136,11 +144,14 @@ export function spawnBrowser ({
console.log(`[Browser] Launching Native: ${execPath}`); console.log(`[Browser] Launching Native: ${execPath}`);
} }
const { signal } = new AbortController();
const processSub = Bun.spawn(cmd, { const processSub = Bun.spawn(cmd, {
env: finalEnv, env: finalEnv,
stdin: "ignore", stdin: "ignore",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
detached,
signal,
ipc, ipc,
onExit (_proc, exitCode) onExit (_proc, exitCode)
{ {
@ -157,6 +168,17 @@ export function spawnBrowser ({
return processSub; 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 --- // --- Test Run ---
// spawnBrowser({ // spawnBrowser({

View file

@ -3,11 +3,11 @@ import
FocusContext, FocusContext,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { FrontEndId, GameMeta } from "../../shared/constants"; import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardParams } from "./GameCard"; import GameCard, { GameCardParams } from "./GameCard";
import { JSX, useState } from "react"; import { JSX } from "react";
import classNames from "classnames";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
export interface GameMetaExtra extends GameMeta export interface GameMetaExtra extends GameMeta
{ {
@ -22,7 +22,7 @@ export function CardList (data: {
games: GameMetaExtra[]; games: GameMetaExtra[];
grid?: boolean; grid?: boolean;
onSelectGame?: (id: string) => void; onSelectGame?: (id: string) => void;
onGameFocus?: (id: string) => void; onGameFocus?: (id: string, node: HTMLElement) => void;
className?: string; className?: string;
}) })
{ {
@ -30,13 +30,21 @@ export function CardList (data: {
focusKey: data.id, focusKey: data.id,
}); });
function BuildGame (g: GameMetaExtra, i: number) function BuildCard (g: GameMetaExtra, i: number)
{ {
let preview: GameCardParams['preview'] = g.preview; let preview: GameCardParams['preview'] = g.preview;
if (!preview && g.previewUrl) if (!preview && g.previewUrl)
{ {
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 ( return (
<GameCard <GameCard
key={g.id} key={g.id}
@ -46,17 +54,12 @@ export function CardList (data: {
data-index={i} data-index={i}
title={g.title} title={g.title}
subtitle={g.subtitle ?? ""} subtitle={g.subtitle ?? ""}
onFocus={() => onFocus={(id, node) =>
{ {
g.onFocus?.(); g.onFocus?.();
data.onGameFocus?.(g.id); data.onGameFocus?.(id, node);
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
}}
onAction={() =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
}} }}
onAction={handleAction}
preview={preview} preview={preview}
badges={g.badges} badges={g.badges}
id={g.id} id={g.id}
@ -82,7 +85,7 @@ export function CardList (data: {
style={{ scrollbarWidth: "none" }} style={{ scrollbarWidth: "none" }}
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
{data.games.map(BuildGame)} {data.games.map(BuildCard)}
</FocusContext.Provider> </FocusContext.Provider>
</ul> </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 { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts'; import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus'; import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
export interface CollectionsDetailParams export interface CollectionsDetailParams
{ {
@ -17,6 +20,16 @@ export interface CollectionsDetailParams
footer?: JSX.Element; 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) export function CollectionsDetail (data: CollectionsDetailParams)
{ {
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`; const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
@ -25,6 +38,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`, preferredChildFocusKey: `${focusKey}-list`,
}); });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
return ( return (
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'> <AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
@ -44,7 +60,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div> <div>
{data.footer} {data.footer}
</div> </div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} /> <Shortcuts shortcuts={shortcuts} />
</footer> </footer>
</AnimatedBackground> </AnimatedBackground>
</FocusContext> </FocusContext>

View file

@ -4,6 +4,7 @@ import { createContext, JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useEventListener } from "usehooks-ts"; import { useEventListener } from "usehooks-ts";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as { const ContextDialogContext = createContext({} as {
close: () => void, close: () => void,
@ -75,14 +76,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
} }
}, [data.open]); }, [data.open]);
useEventListener('cancel', (e) => useShortcuts(focusKey, () => [{
label: "Close",
button: GamePadButtonCode.B,
action: () =>
{ {
if (data.open)
{
e.stopPropagation();
data.close(); data.close();
} }
}, ref); }], []);
return <dialog ref={ref} open={data.open} closedby="any" className={ 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", 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"; } from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon"; import SvgIcon from "./SvgIcon";
import classNames from "classnames"; import classNames from "classnames";
import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
function FilterCat ( function FilterCat (
data: { data: {
@ -12,14 +14,25 @@ function FilterCat (
children?: any; children?: any;
active: boolean; active: boolean;
onFocus: () => void; onFocus: () => void;
hasFocusedPeer: boolean;
} & FilterOption, } & FilterOption,
) )
{ {
const { ref, focusSelf, focused } = useFocusable({ const { ref, focusSelf, focused } = useFocusable({
focusKey: data.id, focusKey: data.id,
onFocus: data.onFocus, onFocus: data.onFocus,
onEnterPress: data.onAction, onEnterPress: data.onAction
}); });
const { filter } = useSearch({ from: '/' });
useEffect(() =>
{
if (filter == data.id && data.hasFocusedPeer)
{
focusSelf();
}
}, [filter]);
return ( return (
<li <li
ref={ref} ref={ref}
@ -46,7 +59,14 @@ export function FilterUI (data: {
setSelected: (id: string) => void; 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 ( return (
<div <div
ref={ref} ref={ref}
@ -60,6 +80,7 @@ export function FilterUI (data: {
</li> </li>
{Object.entries(data.options)?.map(([id, option]) => ( {Object.entries(data.options)?.map(([id, option]) => (
<FilterCat <FilterCat
hasFocusedPeer={hasFocusedChild}
id={id} id={id}
key={id} key={id}
onFocus={() => data.setSelected(id)} onFocus={() => data.setSelected(id)}

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import classNames from "classnames";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: { export default function ShortcutPrompt (data: {
id: string;
icon: IconType; icon: IconType;
label?: string; label?: string;
className?: string; className?: string;
@ -11,8 +12,9 @@ export default function ShortcutPrompt (data: {
}) })
{ {
return ( return (
<span <div
onClick={data.onClick} onClick={data.onClick}
style={{ viewTransitionName: data.id }}
className={twMerge( 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", "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", "sm:text-sm",
@ -24,6 +26,6 @@ export default function ShortcutPrompt (data: {
> >
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} /> <SvgIcon className="md:size-8 sm:size-6" icon={data.icon} />
{data.label} {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 ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon'; import { IconType } from './SvgIcon';
export interface Shortcut const iconMap: Record<GamePadButtonCode, IconType> = {
{ [GamePadButtonCode.A]: 'steamdeck_button_a',
icon: IconType; [GamePadButtonCode.B]: 'steamdeck_button_b',
label: string; [GamePadButtonCode.X]: 'steamdeck_button_x',
action?: () => void; [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 ( return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2"> <div className="flex gap-2">
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)} {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> </div>
); );
} }

View file

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './../routes/__root' import { Route as rootRouteImport } from './../routes/__root'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index' 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 SettingsDirectoriesRouteImport } from './../routes/settings/directories'
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
import { Route as SettingsAboutRouteImport } from './../routes/settings/about' import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
@ -29,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({
id: '/emulators',
path: '/emulators',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({ const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({
id: '/directories', id: '/directories',
path: '/directories', path: '/directories',
@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -83,6 +90,7 @@ export interface FileRoutesByTo {
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -95,6 +103,7 @@ export interface FileRoutesById {
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -108,6 +117,7 @@ export interface FileRouteTypes {
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -119,6 +129,7 @@ export interface FileRouteTypes {
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -130,6 +141,7 @@ export interface FileRouteTypes {
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -160,6 +172,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings/emulators': {
id: '/settings/emulators'
path: '/emulators'
fullPath: '/settings/emulators'
preLoaderRoute: typeof SettingsEmulatorsRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/directories': { '/settings/directories': {
id: '/settings/directories' id: '/settings/directories'
path: '/directories' path: '/directories'
@ -216,12 +235,14 @@ interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAccountsRoute: typeof SettingsAccountsRoute SettingsAccountsRoute: typeof SettingsAccountsRoute
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
} }
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAboutRoute: SettingsAboutRoute, SettingsAboutRoute: SettingsAboutRoute,
SettingsAccountsRoute: SettingsAccountsRoute, SettingsAccountsRoute: SettingsAccountsRoute,
SettingsDirectoriesRoute: SettingsDirectoriesRoute, SettingsDirectoriesRoute: SettingsDirectoriesRoute,
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
} }
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( 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 { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterContext } from ".."; import { RouterContext } from "..";
import Notifications from "../components/Notifications";
import { Toaster } from "react-hot-toast";
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent, component: RootComponent,
@ -12,6 +14,8 @@ function RootComponent ()
return ( return (
<div className="w-screen h-screen overflow-hidden"> <div className="w-screen h-screen overflow-hidden">
<Outlet /> <Outlet />
<Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
{import.meta.env.DEV && {import.meta.env.DEV &&
<> <>
<TanStackRouterDevtools position="top-left" /> <TanStackRouterDevtools position="top-left" />

View file

@ -1,5 +1,5 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useEventListener, useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../components/CollectionsDetail'; import { CollectionsDetail } from '../components/CollectionsDetail';
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen'; import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../shared/constants'; import { DefaultRommStaleTime } from '../../shared/constants';
@ -19,8 +19,6 @@ function RouteComponent ()
"home-background", "home-background",
undefined, undefined,
); );
const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
return ( return (
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} /> <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 { Router } from "../..";
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog"; import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
import Shortcuts from "../../components/Shortcuts"; import Shortcuts from "../../components/Shortcuts";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/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. ";
const gameQuery = (source: string, id: number) => queryOptions({ const gameQuery = (source: string, id: number) => queryOptions({
queryKey: ['game', source, id], queryKey: ['game', source, id],
@ -50,53 +49,10 @@ function GameDetailsUIPending ()
</AnimatedBackground>; </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 () 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; }) 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?.source ?? data.game?.id.source}
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail> {data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div> </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"> {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-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></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-full"></div>
<div className="skeleton h-4 w-[80%]"></div> <div className="skeleton h-4 w-[80%]"></div>
</div>} </div>}
</p> </div>
{!!data.game && <ActionButtons key="actions" game={data.game} />} {!!data.game && <ActionButtons key="actions" game={data.game} />}
</div> </div>
</section> </section>
@ -277,6 +233,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
location.reload(); 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) => es.onerror = (event) =>
{ {
const error = (event as any).data?.error; const error = (event as any).data?.error;
@ -415,7 +380,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
error: 'bg-error text-error-content' 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}> <FocusContext value={focusKey}>
<MainActions game={data.game} /> <MainActions game={data.game} />
<AchievementsInfo game={data.game} /> <AchievementsInfo game={data.game} />
@ -488,3 +453,44 @@ function ActionButton (data: {
</button> </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"; } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames"; import classNames from "classnames";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { useEventListener, useLocalStorage } from "usehooks-ts"; import { useEventListener } from "usehooks-ts";
import import
{ {
getCollectionsApiCollectionsGetOptions, getCollectionsApiCollectionsGetOptions,
@ -43,10 +43,14 @@ import { twMerge } from "tailwind-merge";
import Shortcuts from "../components/Shortcuts"; import Shortcuts from "../components/Shortcuts";
import { PlatformsList } from "../components/PlatformsList"; import { PlatformsList } from "../components/PlatformsList";
import { systemApi } from "../scripts/clientApi"; 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("/")({ export const Route = createFileRoute("/")({
component: ConsoleHomeUI, component: ConsoleHomeUI,
validateSearch: z.object({ filter: z.string().optional().default('games') })
}); });
const filters = { 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; }) function HomeListError (data: { focused: boolean; })
{ {
const error = useErrorBoundary(); const error = useErrorBoundary();
@ -112,19 +75,26 @@ function HomeListError (data: { focused: boolean; })
} }
function HomeList (data: { function HomeList (data: {
selectedFilter: keyof typeof filters; selectedFilter: string;
}) })
{ {
const [initFocus, setInitFocus] = useState(false);
const bg = useContext(AnimatedBackgroundContext); const bg = useContext(AnimatedBackgroundContext);
const { ref, focused, focusKey, focusSelf } = useFocusable({ const { ref, focused, focusKey, focusSelf } = useFocusable({
focusKey: "home-list", focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list` preferredChildFocusKey: `${data.selectedFilter}-list`
}); });
const lists = { const handleNodeFocus = (node: HTMLElement) =>
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} />, node.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />, 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 => 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: {}) function MainMenu (data: {})
{ {
const { ref, focusKey, hasFocusedChild } = useFocusable({ const { ref, focusKey, hasFocusedChild } = useFocusable({
@ -234,7 +146,6 @@ function MainMenu (data: {})
trackChildren: true, trackChildren: true,
onBlur: (layout, props, details) => { }, onBlur: (layout, props, details) => { },
}); });
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<ul <ul
@ -278,10 +189,11 @@ function CircleIcon (data: {
icon?: JSX.Element; icon?: JSX.Element;
}) })
{ {
const { ref, focused } = useFocusable({ const { ref, focused, focusKey } = useFocusable({
focusKey: `navigation-icon-${data.label}`, focusKey: `navigation-icon-${data.label}`,
onEnterPress: data.action, onEnterPress: data.action,
}); });
useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]);
const typeClasses = { const typeClasses = {
secondary: "bg-secondary text-secondary-content", secondary: "bg-secondary text-secondary-content",
accent: "bg-accent text-accent-content", accent: "bg-accent text-accent-content",
@ -305,3 +217,84 @@ function CircleIcon (data: {
</li> </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 { rommApi, systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({ export const Route = createFileRoute('/settings/about')({
component: RouteComponent, component: RouteComponent,
@ -50,6 +51,10 @@ function RouteComponent ()
<th>Machine</th> <th>Machine</th>
<td>{systemInfo?.data?.machine}</td> <td>{systemInfo?.data?.machine}</td>
</tr> </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> <tr>
<th>Steam Deck</th> <th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td> <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 { createFileRoute } from '@tanstack/react-router';
import { SettingsOption } from '../../components/options/SettingsOption'; 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')({ export const Route = createFileRoute('/settings/directories')({
component: RouteComponent, 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 () function RouteComponent ()
{ {
const { focus } = Route.useSearch(); const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
preferredChildFocusKey: focus 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}> return <FocusContext value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2"> <ul ref={ref} className="list rounded-box gap-2">
@ -228,15 +21,6 @@ function RouteComponent ()
</div> </div>
</div> </div>
<SettingsOption label="Download Path" id="downloadPath" type="text" /> <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> </ul>
</FocusContext>; </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 import
{ {
Outlet, Outlet,
Link,
createFileRoute, createFileRoute,
useMatchRoute, useMatchRoute,
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { retainSearchParams, ViewTransitionOptions } from "@tanstack/router-core"; import { ViewTransitionOptions } from "@tanstack/router-core";
import classNames from "classnames"; import classNames from "classnames";
import import
{ {
@ -19,16 +18,17 @@ import
FingerprintPattern, FingerprintPattern,
HardDrive, HardDrive,
Info, Info,
Joystick,
MonitorCog, MonitorCog,
} from "lucide-react"; } from "lucide-react";
import { JSX, useEffect, useRef } from "react"; import { JSX, useEffect } from "react";
import { useEventListener } from "usehooks-ts";
import ShortcutPrompt from "../../components/ShortcutPrompt";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import z from "zod"; import z from "zod";
import { SettingsSchema } from "../../../shared/constants"; import { SettingsSchema } from "../../../shared/constants";
import { PopSource } from "../../scripts/spatialNavigation"; import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../.."; import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: SettingsUI, component: SettingsUI,
@ -123,6 +123,12 @@ function SettingsMenu (data: {})
label="Visual" label="Visual"
icon={<MonitorCog />} icon={<MonitorCog />}
/> />
<MenuItem
focusSelect
route="/settings/emulators"
label="Emulators"
icon={<Joystick />}
/>
<MenuItem <MenuItem
focusSelect focusSelect
route="/settings/directories" route="/settings/directories"
@ -172,12 +178,14 @@ export function SettingsUI ()
preferredChildFocusKey: 'settings-menu' preferredChildFocusKey: 'settings-menu'
}); });
useEventListener("cancel", HandleGoBack, ref);
useEffect(() => useEffect(() =>
{ {
focusSelf(); focusSelf();
}, []); }, []);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
return ( return (
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<div ref={ref} className="flex flex-col w-full h-full p-4 bg-base-100"> <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> </div>
<div className="divider divider-end"> <div className="divider divider-end">
<ShortcutPrompt <Shortcuts shortcuts={shortcuts} />
onClick={HandleGoBack}
icon="steamdeck_button_b"
label="Back"
/>
</div> </div>
</div> </div>
</FocusContext.Provider> </FocusContext.Provider>

View file

@ -3,6 +3,7 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
let loopStarted = false; let loopStarted = false;
window.addEventListener("gamepadconnected", (evt) => window.addEventListener("gamepadconnected", (evt) =>
{ {
if (!loopStarted) if (!loopStarted)
@ -11,6 +12,7 @@ window.addEventListener("gamepadconnected", (evt) =>
loopStarted = true; loopStarted = true;
} }
}); });
window.addEventListener("gamepaddisconnected", (evt) => 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 () function updateStatus ()
{ {
@ -53,32 +68,25 @@ function updateStatus ()
{ {
const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, }); 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')) const button = gamepad.buttons[i];
{ const key = String(i);
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' }));
}
}
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 }); window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad }));
dispatchFocusedEvent(evn); throttleMap.set(key, 0);
throttleMap.set('cancel', 0);
} }
} else } else
{ {
throttleMap.delete('cancel'); if (throttleMap.delete(key))
{
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad }));
}
}
} }
const activeFocus = GetFocusedElement(getCurrentFocusKey()); 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 SaveFocusType = "session" | "local";
type HistorySourceType = "settings" | 'details' | 'launch'; type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list';
const historySourceMap = new Map<string, string>(); const historySourceMap = new Map<string, string>();
export function SaveSource (id: HistorySourceType, url?: 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) export function HasSource (id: HistorySourceType)
@ -46,6 +51,27 @@ export function GetFocusedElement (focusKey: string)
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement; 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) export function dispatchFocusedEvent (event: Event, override?: Element | Window)
{ {
const focusedElement = GetFocusedElement(getCurrentFocusKey()); 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 type SettingsType = z.infer<typeof SettingsSchema>;
export interface GameInstallProgress export interface GameInstallProgress
{ {