feat: Moved to stream zip downloading.
feat: Implemented Shortcuts. feat: Ensured it works on steam deck
This commit is contained in:
parent
f15bf9a1e0
commit
62f16cbcc1
45 changed files with 1415 additions and 631 deletions
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -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
37
.vscode/tasks.json
vendored
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
18
bun.lock
18
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
17
gameflow-deck.code-workspace
Normal file
17
gameflow-deck.code-workspace
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
36
scripts/dev.ts
Normal 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();
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
@ -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,29 +217,89 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const game = setActiveGame({
|
try
|
||||||
process: Bun.spawn({
|
|
||||||
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
|
|
||||||
{
|
|
||||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
name: localGame?.name ?? "Unknown",
|
|
||||||
gameId: validCommand.gameId,
|
|
||||||
command: validCommand.command.command
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.process.exited;
|
|
||||||
if (game.process.exitCode && game.process.exitCode > 0)
|
|
||||||
{
|
{
|
||||||
return status('Internal Server Error');
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
const game = spawn(validCommand.command.command, {
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
game.stdout.on('data', data => console.log(data));
|
||||||
|
game.on('close', (code) =>
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { exitCode: code, signalCode: null });
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
game.on('error', e =>
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { exitCode: null, signalCode: null, error: e });
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveGame({
|
||||||
|
pid: game.pid,
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: validCommand.gameId,
|
||||||
|
command: validCommand.command.command
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateRommProps (id: number)
|
||||||
|
{
|
||||||
|
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||||
|
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'romm')
|
||||||
|
{
|
||||||
|
updateRommProps(id);
|
||||||
|
}
|
||||||
|
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||||
|
{
|
||||||
|
updateRommProps(localGame.source_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||||
|
const game = setActiveGame({
|
||||||
|
process: Bun.spawn({
|
||||||
|
cmd,
|
||||||
|
env: {
|
||||||
|
...process.env
|
||||||
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
}),
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: validCommand.gameId,
|
||||||
|
command: validCommand.command.command
|
||||||
|
});
|
||||||
|
|
||||||
|
await game.process.exited;
|
||||||
|
if (game.process.exitCode && game.process.exitCode > 0)
|
||||||
|
{
|
||||||
|
return status('Internal Server Error');
|
||||||
|
}*/
|
||||||
|
return status('OK');
|
||||||
|
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
return status('Internal Server Error', getErrorMessage(error));
|
||||||
}
|
}
|
||||||
return status('OK');
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -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,40 +79,79 @@ 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);
|
let emulator: string | undefined = undefined;
|
||||||
if (matches)
|
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) =>
|
||||||
{
|
{
|
||||||
let emulator: string | undefined = undefined;
|
try
|
||||||
const varList = await Promise.all(matches.map(async (value) =>
|
|
||||||
{
|
{
|
||||||
if (value.startsWith("%EMULATOR_"))
|
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
|
||||||
{
|
{
|
||||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
return staticVars[a] ?? a;
|
||||||
let exec = await findExec(emulatorName);
|
});
|
||||||
if (data.customEmulatorConfig.has(emulatorName))
|
if (existsSync(resolvedInjectFile))
|
||||||
{
|
{
|
||||||
exec = data.customEmulatorConfig.get(emulatorName);
|
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
|
||||||
}
|
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
|
||||||
|
|
||||||
emulator = emulatorName;
|
|
||||||
return [value, exec];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = value.substring(1, value.length - 1);
|
return '';
|
||||||
return [value, process.env[key]];
|
} catch (error)
|
||||||
}));
|
{
|
||||||
const vars = Object.fromEntries(varList);
|
return '';
|
||||||
vars['%ROM%'] = validFiles[0];
|
}
|
||||||
vars['%ESPATH%'] = config.get('downloadPath');
|
});
|
||||||
|
|
||||||
// missing variable
|
const matches = Array.from(cmd.matchAll(varRegex));
|
||||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
const varList = await Promise.all(matches.map(async ([value]) =>
|
||||||
|
{
|
||||||
|
if (value.startsWith("%EMULATOR_"))
|
||||||
|
{
|
||||||
|
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||||
|
let exec = await findExec(emulatorName);
|
||||||
|
if (data.customEmulatorConfig.has(emulatorName))
|
||||||
|
{
|
||||||
|
exec = data.customEmulatorConfig.get(emulatorName);
|
||||||
|
}
|
||||||
|
|
||||||
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
|
emulator = emulatorName;
|
||||||
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
|
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = value[0].substring(1, value.length - 1);
|
||||||
|
return [[value, process.env[key]]];
|
||||||
|
}));
|
||||||
|
|
||||||
|
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||||
|
vars['%ESCAPESPECIALS%'] = "";
|
||||||
|
vars['%HIDEWINDOW%'] = '';
|
||||||
|
|
||||||
|
// missing variable
|
||||||
|
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||||
|
|
||||||
|
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label ?? undefined,
|
||||||
|
command: formattedCommand,
|
||||||
|
valid: !invalid, emulator
|
||||||
|
} satisfies CommandEntry;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return formattedCommands.filter(c => !!c);
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,9 +119,19 @@ export default async function buildStatusResponse (source: string, id: number)
|
||||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||||
}
|
}
|
||||||
|
|
||||||
} else
|
} else if (source === 'romm')
|
||||||
{
|
{
|
||||||
enqueue({ status: 'install', details: 'Install' });
|
// TODO: Add Caching
|
||||||
|
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
||||||
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
|
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||||
|
{
|
||||||
|
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
enqueue({ status: 'install', details: 'Install' });
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,8 +139,15 @@ export default async function buildStatusResponse (source: string, id: number)
|
||||||
await sendLatests();
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
28
src/bun/api/notifications.ts
Normal file
28
src/bun/api/notifications.ts
Normal 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?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 } }) =>
|
||||||
|
|
|
||||||
|
|
@ -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
100
src/bun/browser.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
events.on('exitapp', () =>
|
||||||
|
{
|
||||||
|
process.send?.({ type: 'exitapp' });
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
try
|
await init(events, !!Bun.env.FORCE_BROWSER);
|
||||||
{
|
|
||||||
await runWebview();
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
await runBrowser();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWebview ()
|
|
||||||
{
|
|
||||||
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
|
|
||||||
smol: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
|
||||||
{
|
|
||||||
webviewWorker.addEventListener('error', e =>
|
|
||||||
{
|
|
||||||
console.error(e.message);
|
|
||||||
reject(e.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
webviewWorker.addEventListener('message', (e) =>
|
|
||||||
{
|
|
||||||
if (e.data === 'destroyed')
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
events.on('exitapp', () =>
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBrowser ()
|
|
||||||
{
|
|
||||||
const browserParams = await BuildParams();
|
|
||||||
if (!browserParams)
|
|
||||||
{
|
|
||||||
console.error("Could not find valid browser");
|
|
||||||
await cleanup();
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const browser = spawnBrowser({
|
|
||||||
browser: browserParams.browser.type,
|
|
||||||
args: browserParams.args,
|
|
||||||
env: browserParams.env,
|
|
||||||
detached: false,
|
|
||||||
execPath: browserParams.browser.path,
|
|
||||||
source: browserParams.browser.source,
|
|
||||||
ipc (message)
|
|
||||||
{
|
|
||||||
console.log(message);
|
|
||||||
},
|
|
||||||
onExit: cleanup
|
|
||||||
});
|
|
||||||
|
|
||||||
events.on('exitapp', () => browser.kill(15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
2
src/bun/types/types.d.ts
vendored
2
src/bun/types/types.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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')}`);
|
||||||
|
|
|
||||||
|
|
@ -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[]; }> = {
|
||||||
|
|
@ -91,7 +99,7 @@ export function spawnBrowser ({
|
||||||
|
|
||||||
const target = config[browser];
|
const target = config[browser];
|
||||||
const useFlatpak = source === "flatpak";
|
const useFlatpak = source === "flatpak";
|
||||||
|
|
||||||
let cmd: string[];
|
let cmd: string[];
|
||||||
let finalEnv: Record<string, string> | undefined;
|
let finalEnv: Record<string, string> | undefined;
|
||||||
|
|
||||||
|
|
@ -100,9 +108,9 @@ export function spawnBrowser ({
|
||||||
// --- Flatpak Mode (Steam Style) ---
|
// --- Flatpak Mode (Steam Style) ---
|
||||||
// Structure: flatpak run [ENV] [FLATPAK_OPTS] [APP_ID] @@u @@ [USER_ARGS]
|
// Structure: flatpak run [ENV] [FLATPAK_OPTS] [APP_ID] @@u @@ [USER_ARGS]
|
||||||
// The @@u @@ syntax enables file forwarding for URL arguments
|
// The @@u @@ syntax enables file forwarding for URL arguments
|
||||||
|
|
||||||
const envFlags = Object.entries(env).map(([k, v]) => `--env=${k}=${v}`);
|
const envFlags = Object.entries(env).map(([k, v]) => `--env=${k}=${v}`);
|
||||||
|
|
||||||
// We explicitly set the command to ensure we don't rely on the default entrypoint failing
|
// We explicitly set the command to ensure we don't rely on the default entrypoint failing
|
||||||
const flatpakOpts = [
|
const flatpakOpts = [
|
||||||
"run",
|
"run",
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
54
src/mainview/components/CollectionList.tsx
Normal file
54
src/mainview/components/CollectionList.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
if (data.open)
|
button: GamePadButtonCode.B,
|
||||||
|
action: () =>
|
||||||
{
|
{
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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 }))}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/mainview/components/Notifications.tsx
Normal file
38
src/mainview/components/Notifications.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) }} />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -487,4 +452,45 @@ function ActionButton (data: {
|
||||||
{data.children}
|
{data.children}
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -304,4 +216,85 @@ function CircleIcon (data: {
|
||||||
{data.icon}
|
{data.icon}
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
246
src/mainview/routes/settings/emulators.tsx
Normal file
246
src/mainview/routes/settings/emulators.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
|
||||||
{
|
{
|
||||||
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
if (!throttleMap.has(key))
|
||||||
dispatchFocusedEvent(evn);
|
{
|
||||||
throttleMap.set('cancel', 0);
|
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad }));
|
||||||
|
throttleMap.set(key, 0);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
if (throttleMap.delete(key))
|
||||||
|
{
|
||||||
|
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else
|
|
||||||
{
|
|
||||||
throttleMap.delete('cancel');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFocus = GetFocusedElement(getCurrentFocusKey());
|
const activeFocus = GetFocusedElement(getCurrentFocusKey());
|
||||||
|
|
|
||||||
137
src/mainview/scripts/shortcuts.ts
Normal file
137
src/mainview/scripts/shortcuts.ts
Normal 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]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue