feat: implemented storage management
fix: Enabled fallback secrets feat: Made header stats actually work feat: Made steam deck keyboard auto open for some inputs fix: Made keybaord also work with shortcuts (no tooltips yet)
This commit is contained in:
parent
62f16cbcc1
commit
e4df8fb9fb
55 changed files with 1675 additions and 398 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -35,8 +35,5 @@
|
||||||
"WAYLAND_DISPLAY": "wayland-0",
|
"WAYLAND_DISPLAY": "wayland-0",
|
||||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||||
"GPG_TTY": "/dev/tty"
|
"GPG_TTY": "/dev/tty"
|
||||||
},
|
}
|
||||||
"terminal.integrated.shellArgs.linux": [
|
|
||||||
"-l"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
21
bun.lock
21
bun.lock
|
|
@ -14,17 +14,22 @@
|
||||||
"conf": "^15.0.2",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.22",
|
||||||
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.10",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
|
"systeminformation": "^5.31.1",
|
||||||
"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",
|
"unzip-stream": "^0.3.4",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ap0nia/eden": "^1.0.0-next.22",
|
||||||
|
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||||
"@hey-api/openapi-ts": "^0.91.0",
|
"@hey-api/openapi-ts": "^0.91.0",
|
||||||
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|
@ -35,7 +40,9 @@
|
||||||
"@tanstack/react-router-devtools": "^1.154.12",
|
"@tanstack/react-router-devtools": "^1.154.12",
|
||||||
"@tanstack/react-router-ssr-query": "^1.157.17",
|
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||||
"@tanstack/router-plugin": "^1.157.16",
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@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",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
|
|
@ -69,6 +76,10 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="],
|
||||||
|
|
||||||
|
"@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="],
|
||||||
|
|
||||||
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
|
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||||
|
|
@ -385,6 +396,8 @@
|
||||||
|
|
||||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
|
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
|
||||||
|
|
||||||
|
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.162.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-sO4n2o9F7gZKHZb/nW/fMcDaeVcbFZ2a7zCA+GkaHJwRmhKKlQQ0dae9pc8wOMMG+QkfH1Wysq+tg2RNvm/kpg=="],
|
||||||
|
|
||||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||||
|
|
||||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||||
|
|
@ -405,8 +418,12 @@
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
||||||
|
|
@ -737,6 +754,8 @@
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
|
"node-disk-info": ["node-disk-info@1.3.0", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-NEx858vJZ0AoBtmD/ChBIHLjFTF28xCsDIgmFl4jtGKsvlUx9DU/OrMDjvj3qp/E4hzLN0HvTg7eJx5XFQvbeg=="],
|
||||||
|
|
||||||
"node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="],
|
"node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="],
|
||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
@ -907,6 +926,8 @@
|
||||||
|
|
||||||
"sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="],
|
"sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="],
|
||||||
|
|
||||||
|
"systeminformation": ["systeminformation@5.31.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA=="],
|
||||||
|
|
||||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,22 @@
|
||||||
"conf": "^15.0.2",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.22",
|
||||||
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.10",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
|
"systeminformation": "^5.31.1",
|
||||||
"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",
|
"unzip-stream": "^0.3.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ap0nia/eden": "^1.0.0-next.22",
|
||||||
|
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||||
"@hey-api/openapi-ts": "^0.91.0",
|
"@hey-api/openapi-ts": "^0.91.0",
|
||||||
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|
@ -51,7 +56,9 @@
|
||||||
"@tanstack/react-router-devtools": "^1.154.12",
|
"@tanstack/react-router-devtools": "^1.154.12",
|
||||||
"@tanstack/react-router-ssr-query": "^1.157.17",
|
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||||
"@tanstack/router-plugin": "^1.157.16",
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@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",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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";
|
import { getErrorMessage } from "../utils";
|
||||||
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
|
|
||||||
export const config = new Conf<SettingsType>({
|
export const config = new Conf<SettingsType>({
|
||||||
projectName: projectPackage.name,
|
projectName: projectPackage.name,
|
||||||
|
|
@ -44,9 +45,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path),
|
||||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||||
export const jar = new CookieJar(fileCookieStore);
|
export const jar = new CookieJar(fileCookieStore);
|
||||||
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||||
const sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
let sqlite: Database;
|
||||||
export const db = drizzle(sqlite, { schema });
|
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||||
migrate(db, { migrationsFolder: "./drizzle" });
|
await reloadDatabase();
|
||||||
|
migrate(db!, { migrationsFolder: "./drizzle" });
|
||||||
const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true });
|
const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true });
|
||||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||||
export const taskQueue = new TaskQueue();
|
export const taskQueue = new TaskQueue();
|
||||||
|
|
@ -77,9 +79,15 @@ export async function cleanup ()
|
||||||
emulatorsSqlite.close();
|
emulatorsSqlite.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reloadDatabase ()
|
||||||
|
{
|
||||||
|
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||||
|
db = drizzle(sqlite, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
interface AppEventMap
|
interface AppEventMap
|
||||||
{
|
{
|
||||||
activegameexit: [{ subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||||
exitapp: [];
|
exitapp: [];
|
||||||
notification: [Notification];
|
notification: [Notification];
|
||||||
}
|
}
|
||||||
91
src/bun/api/drives.ts
Normal file
91
src/bun/api/drives.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { Drive } from "@/shared/constants";
|
||||||
|
import si from 'systeminformation';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
async function getAccess (path: string)
|
||||||
|
{
|
||||||
|
let hasWriteAccess = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fs.promises.access(path, fs.constants.W_OK);
|
||||||
|
hasWriteAccess = true;
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasReadAccesss = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fs.promises.access(path, fs.constants.R_OK);
|
||||||
|
hasReadAccesss = true;
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return [hasReadAccesss, hasWriteAccess];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevices (): Promise<Drive[]>
|
||||||
|
{
|
||||||
|
const blockDevicesRaw = await si.blockDevices();
|
||||||
|
const layout = await si.diskLayout();
|
||||||
|
const blockDevices = blockDevicesRaw.filter(l => l.device && l.type === 'part' && l.mount);
|
||||||
|
const fsSizes = await si.fsSize();
|
||||||
|
const sizes = new Map(fsSizes.map(s => [s.mount, s]));
|
||||||
|
const layoutMap = new Map(layout.map(l => [l.device, l]));
|
||||||
|
return await Promise.all(blockDevices.map(async l =>
|
||||||
|
{
|
||||||
|
const size = sizes.get(l.mount);
|
||||||
|
const layout = layoutMap.get(l.device!);
|
||||||
|
const [hasReadAccess, hasWriteAccess] = await getAccess(l.mount);
|
||||||
|
const drive: Drive = {
|
||||||
|
parent: l.group || null,
|
||||||
|
device: l.device ?? '',
|
||||||
|
label: l.label || l.name,
|
||||||
|
mountPoint: l.mount,
|
||||||
|
type: l.type as any,
|
||||||
|
size: l.size,
|
||||||
|
used: size?.used ?? l.size,
|
||||||
|
isRemovable: l.removable,
|
||||||
|
interfaceType: layout?.interfaceType || null,
|
||||||
|
hasReadAccess,
|
||||||
|
hasWriteAccess
|
||||||
|
};
|
||||||
|
return drive;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets hand picked locations on drives that you have permission to write to
|
||||||
|
export async function getDevicesCurated (): Promise<Drive[]>
|
||||||
|
{
|
||||||
|
const drives: Drive[] = [];
|
||||||
|
const devices = await getDevices();
|
||||||
|
drives.push(...devices.filter(d => d.hasWriteAccess));
|
||||||
|
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const homeDirDevice = devices.filter(d => d.mountPoint).reverse()
|
||||||
|
.find(d => homeDir.startsWith(d.mountPoint!));
|
||||||
|
if (homeDirDevice)
|
||||||
|
{
|
||||||
|
const [hasReadAccess, hasWriteAccess] = await getAccess(homeDir);
|
||||||
|
|
||||||
|
drives.push({
|
||||||
|
parent: homeDirDevice.parent,
|
||||||
|
device: homeDirDevice.device,
|
||||||
|
size: homeDirDevice.size,
|
||||||
|
used: homeDirDevice.used,
|
||||||
|
isRemovable: homeDirDevice.isRemovable,
|
||||||
|
mountPoint: homeDir,
|
||||||
|
type: homeDirDevice.type,
|
||||||
|
label: 'Home',
|
||||||
|
interfaceType: homeDirDevice.interfaceType,
|
||||||
|
hasReadAccess,
|
||||||
|
hasWriteAccess
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return drives;
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,15 @@ import { and, eq, getTableColumns } from "drizzle-orm";
|
||||||
import z from "zod";
|
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, GameListFilterSchema } from "@shared/constants";
|
||||||
import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } 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 { launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
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 }) =>
|
||||||
|
|
@ -55,16 +55,19 @@ export default new Elysia()
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.number() }),
|
params: z.object({ id: z.number() }),
|
||||||
response: z.object({ installed: z.boolean() })
|
response: z.object({ installed: z.boolean() })
|
||||||
}).get('/games', async ({ query: { platform_id, collection_id } }) =>
|
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
|
||||||
{
|
{
|
||||||
const where: any[] = [];
|
const where: any[] = [];
|
||||||
if (platform_id)
|
if (platform_slug)
|
||||||
{
|
{
|
||||||
where.push(eq(schema.games.id, platform_id));
|
where.push(eq(schema.platforms.slug, platform_slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
const games: FrontEndGameType[] = [];
|
const games: FrontEndGameType[] = [];
|
||||||
|
let localGamesSet: Set<number> | undefined;
|
||||||
|
|
||||||
|
if (!collection_id)
|
||||||
|
{
|
||||||
const localGames = await db.select({
|
const localGames = await db.select({
|
||||||
platform_display_name: schema.platforms.name,
|
platform_display_name: schema.platforms.name,
|
||||||
id: schema.games.id,
|
id: schema.games.id,
|
||||||
|
|
@ -76,9 +79,11 @@ export default new Elysia()
|
||||||
path_fs: schema.games.path_fs,
|
path_fs: schema.games.path_fs,
|
||||||
source_id: schema.games.source_id,
|
source_id: schema.games.source_id,
|
||||||
source: schema.games.source
|
source: schema.games.source
|
||||||
}).from(schema.games).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)).where(and(...where));
|
}).from(schema.games)
|
||||||
|
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id))
|
||||||
|
.where(and(...where));
|
||||||
|
|
||||||
const localGamesSet = new Set(localGames.map(g => g.source_id));
|
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
|
||||||
games.push(...localGames.map(g =>
|
games.push(...localGames.map(g =>
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
|
|
@ -93,16 +98,21 @@ export default new Elysia()
|
||||||
};
|
};
|
||||||
return game;
|
return game;
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!platform_source || platform_source === 'romm') || !!collection_id)
|
||||||
|
{
|
||||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
||||||
games.push(...rommGames.data.items.filter(g => !localGamesSet.has(g.id)).map(g =>
|
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
|
||||||
{
|
{
|
||||||
return convertRomToFrontend(g);
|
return convertRomToFrontend(g);
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return { games };
|
return { games };
|
||||||
}, {
|
}, {
|
||||||
query: z.object({ platform_id: z.coerce.number().optional(), collection_id: z.coerce.number().optional() }),
|
query: GameListFilterSchema,
|
||||||
})
|
})
|
||||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
|
|
@ -188,7 +198,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
if (!taskQueue.hasActive())
|
if (!taskQueue.hasActive())
|
||||||
{
|
{
|
||||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id));
|
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||||
return status(200);
|
return status(200);
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -209,97 +219,14 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
||||||
if (activeGame && activeGame.process.killed === false)
|
|
||||||
{
|
|
||||||
return status('Conflict', `${activeGame.name} currently running`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const localGame = await db.query.games.findFirst({
|
|
||||||
where: eq(schema.games.id, validCommand.gameId), columns: {
|
|
||||||
name: true,
|
|
||||||
source_id: true,
|
|
||||||
source: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await new Promise((resolve, reject) =>
|
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
|
||||||
{
|
|
||||||
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)
|
} catch (error)
|
||||||
{
|
{
|
||||||
|
console.error(error);
|
||||||
return status('Internal Server Error', getErrorMessage(error));
|
return status('Internal Server Error', getErrorMessage(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@ export default new Elysia()
|
||||||
const platforms: FrontEndPlatformType[] = [];
|
const platforms: FrontEndPlatformType[] = [];
|
||||||
let rommPlatformsSet: Set<string> | undefined;
|
let rommPlatformsSet: Set<string> | undefined;
|
||||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||||
|
|
||||||
|
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||||
|
.from(schema.platforms)
|
||||||
|
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
|
||||||
|
.groupBy(schema.platforms.id);
|
||||||
|
|
||||||
|
const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug));
|
||||||
|
|
||||||
if (rommPlatforms)
|
if (rommPlatforms)
|
||||||
{
|
{
|
||||||
const frontEndPlatforms = rommPlatforms.map(p =>
|
const frontEndPlatforms = rommPlatforms.map(p =>
|
||||||
|
|
@ -24,22 +32,17 @@ export default new Elysia()
|
||||||
game_count: p.rom_count,
|
game_count: p.rom_count,
|
||||||
updated_at: new Date(p.updated_at),
|
updated_at: new Date(p.updated_at),
|
||||||
id: { source: 'romm', id: p.id },
|
id: { source: 'romm', id: p.id },
|
||||||
source: null,
|
hasLocal: localPlatformSet.has(p.slug)
|
||||||
source_id: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return platform;
|
return platform;
|
||||||
});
|
});
|
||||||
|
|
||||||
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
|
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
|
||||||
platforms.push(...frontEndPlatforms);
|
platforms.push(...frontEndPlatforms);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
platforms.push(...localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(p =>
|
||||||
.from(schema.platforms)
|
|
||||||
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
|
|
||||||
.groupBy(schema.platforms.id)
|
|
||||||
.where(notInArray(schema.platforms.slug, Array.from(rommPlatformsSet ?? [])));
|
|
||||||
platforms.push(...localPlatforms.map(p =>
|
|
||||||
{
|
{
|
||||||
const platform: FrontEndPlatformType = {
|
const platform: FrontEndPlatformType = {
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
|
|
@ -49,8 +52,7 @@ export default new Elysia()
|
||||||
game_count: p.game_count,
|
game_count: p.game_count,
|
||||||
updated_at: p.created_at,
|
updated_at: p.created_at,
|
||||||
id: { source: 'local', id: p.id },
|
id: { source: 'local', id: p.id },
|
||||||
source: null,
|
hasLocal: true
|
||||||
source_id: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return platform;
|
return platform;
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import { which } from 'bun';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import * as schema from '../../schema/emulators';
|
import * as schema from '../../schema/emulators';
|
||||||
|
import * as appSchema from "../../schema/app";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { config, emulatorsDb } from '../../app';
|
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { $ } from 'bun';
|
import { $ } from 'bun';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||||
|
|
||||||
export const varRegex = /%([^%]+)%/g;
|
export const varRegex = /%([^%]+)%/g;
|
||||||
|
|
||||||
|
|
@ -18,6 +21,92 @@ interface CommandEntry
|
||||||
emulator?: string;
|
emulator?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
|
||||||
|
{
|
||||||
|
if (activeGame && activeGame.process?.killed === false)
|
||||||
|
{
|
||||||
|
throw new Error(`${activeGame.name} currently running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localGame = await db.query.games.findFirst({
|
||||||
|
where: eq(appSchema.games.id, id), columns: {
|
||||||
|
name: true,
|
||||||
|
source_id: true,
|
||||||
|
source: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
const game = spawn(validCommand, {
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
game.stdout.on('data', data => console.log(data));
|
||||||
|
game.on('close', (code) =>
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
game.on('error', e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
events.emit('notification', { message: e.message, type: 'error' });
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveGame({
|
||||||
|
process: game,
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: id,
|
||||||
|
command: validCommand
|
||||||
|
});
|
||||||
|
|
||||||
|
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(sourceId);
|
||||||
|
}
|
||||||
|
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||||
|
{
|
||||||
|
updateRommProps(localGame.source_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||||
|
|
||||||
|
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');
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
export async function getValidLaunchCommands (data: {
|
export async function getValidLaunchCommands (data: {
|
||||||
systemSlug: string;
|
systemSlug: string;
|
||||||
gamePath: string;
|
gamePath: string;
|
||||||
|
|
@ -90,11 +179,11 @@ export async function getValidLaunchCommands (data: {
|
||||||
const staticVars: Record<string, string> = {
|
const staticVars: Record<string, string> = {
|
||||||
'%ROM%': $.escape(rom),
|
'%ROM%': $.escape(rom),
|
||||||
'%ROMRAW%': validFiles[0],
|
'%ROMRAW%': validFiles[0],
|
||||||
'%ROMRAWWIN%': validFiles[0].replace('/', '\\'),
|
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
|
||||||
'%ESPATH%': path.dirname(Bun.main),
|
'%ESPATH%': $.escape(path.dirname(Bun.main)),
|
||||||
'%ROMPATH%': $.escape(gamePath),
|
'%ROMPATH%': $.escape(gamePath),
|
||||||
'%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])),
|
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
|
||||||
'%FILENAME%': path.basename(validFiles[0])
|
'%FILENAME%': $.escape(path.basename(validFiles[0]))
|
||||||
};
|
};
|
||||||
|
|
||||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
|
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
|
||||||
|
|
|
||||||
|
|
@ -79,19 +79,39 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||||
export default async function buildStatusResponse (source: string, id: number)
|
export default async function buildStatusResponse (source: string, id: number)
|
||||||
{
|
{
|
||||||
let cleanup: (() => void) | undefined;
|
let cleanup: (() => void) | undefined;
|
||||||
|
let closed = false;
|
||||||
return new Response(new ReadableStream({
|
return new Response(new ReadableStream({
|
||||||
async start (controller)
|
async start (controller)
|
||||||
{
|
{
|
||||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
||||||
{
|
{
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
const evntString = event ? `event: ${event}\n` : '';
|
||||||
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
|
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendLatests();
|
||||||
|
|
||||||
|
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
||||||
|
const keepAlive = setInterval(() =>
|
||||||
|
{
|
||||||
|
if (closed) return clearInterval(keepAlive);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
enqueue({}, 'ping');
|
||||||
|
} catch
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
const sourceId = `${source}-${id}`;
|
const sourceId = `${source}-${id}`;
|
||||||
|
|
||||||
async function sendLatests ()
|
async function sendLatests ()
|
||||||
{
|
{
|
||||||
|
if (closed) return;
|
||||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
||||||
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
||||||
if (activeTask)
|
if (activeTask)
|
||||||
|
|
@ -136,8 +156,6 @@ export default async function buildStatusResponse (source: string, id: number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendLatests();
|
|
||||||
|
|
||||||
const dispose: Function[] = [];
|
const dispose: Function[] = [];
|
||||||
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
||||||
{
|
{
|
||||||
|
|
@ -179,6 +197,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
||||||
|
|
||||||
cleanup = () =>
|
cleanup = () =>
|
||||||
{
|
{
|
||||||
|
closed = true;
|
||||||
dispose.forEach(f => f());
|
dispose.forEach(f => f());
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,13 @@ import { IJob, JobContext } from "../task-queue";
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { and, 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 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 { downloadRomsApiRomsDownloadGet, getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
import { 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 unzip from 'unzip-stream';
|
||||||
import { Readable, Transform } from "node:stream";
|
import { Readable, Transform } from "node:stream";
|
||||||
import { createWriteStream } from "node:fs";
|
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
|
|
@ -22,13 +19,17 @@ interface JobConfig
|
||||||
export class InstallJob implements IJob
|
export class InstallJob implements IJob
|
||||||
{
|
{
|
||||||
public id: number;
|
public id: number;
|
||||||
|
public source: string;
|
||||||
|
public sourceId: number;
|
||||||
|
|
||||||
public config?: JobConfig;
|
public config?: JobConfig;
|
||||||
|
|
||||||
constructor(id: number, config?: JobConfig)
|
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
|
||||||
{
|
{
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.sourceId = sourceId;
|
||||||
|
this.source = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start (cx: JobContext)
|
public async start (cx: JobContext)
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,33 @@ import { events } from './app';
|
||||||
|
|
||||||
export default function buildNotificationsStream ()
|
export default function buildNotificationsStream ()
|
||||||
{
|
{
|
||||||
|
let closed = false;
|
||||||
let cleanup: (() => void) | undefined = undefined;
|
let cleanup: (() => void) | undefined = undefined;
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
async start (controller)
|
async start (controller)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
function enqueue (data: Notification, event?: 'notification')
|
function enqueue (data: Notification, event?: 'notification')
|
||||||
{
|
{
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
const evntString = event ? `event: ${event}\n` : '';
|
||||||
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
|
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
||||||
|
const keepAlive = setInterval(() =>
|
||||||
|
{
|
||||||
|
if (closed) return clearInterval(keepAlive);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
controller.enqueue(encoder.encode(`: ping\n\n`));
|
||||||
|
} catch
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
const notificationHandler = (notification: Notification) =>
|
const notificationHandler = (notification: Notification) =>
|
||||||
{
|
{
|
||||||
enqueue(notification, 'notification');
|
enqueue(notification, 'notification');
|
||||||
|
|
@ -23,6 +40,7 @@ export default function buildNotificationsStream ()
|
||||||
cancel: () =>
|
cancel: () =>
|
||||||
{
|
{
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
|
closed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import { RPC_PORT } from "../../shared/constants";
|
import { RPC_PORT } from "../../shared/constants";
|
||||||
import { host } from "../utils";
|
|
||||||
import clients from "./clients";
|
import clients from "./clients";
|
||||||
import { settings } from "./settings";
|
import { settings } from "./settings";
|
||||||
import { system } from "./system";
|
import { system } from "./system";
|
||||||
|
import { host } from "../utils/host";
|
||||||
|
|
||||||
const api = new Elysia({ serve: {} })
|
const api = new Elysia({ serve: {} })
|
||||||
.use([cors(), clients, settings, system]);
|
.use([cors(), clients, settings, system]);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ class FallbackSecrets implements ISecrets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Bun.secrets.get({ service: 'test', name: 'test' });
|
await Bun.secrets.get({ service: 'test', name: 'test' });
|
||||||
|
|
@ -126,8 +125,6 @@ try
|
||||||
} catch
|
} catch
|
||||||
{
|
{
|
||||||
secrets = new FallbackSecrets();
|
secrets = new FallbackSecrets();
|
||||||
}*/
|
}
|
||||||
|
|
||||||
secrets = new FallbackSecrets();
|
|
||||||
|
|
||||||
export default secrets;
|
export default secrets;
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SettingsSchema } from "@shared/constants";
|
import { SettingsSchema } from "@shared/constants";
|
||||||
import Elysia from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, customEmulators, db, emulatorsDb } from "./app";
|
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
|
||||||
import * as appSchema from './schema/app';
|
import * as appSchema from './schema/app';
|
||||||
import { findExec } from "./games/services/launchGameService";
|
import { findExec } from "./games/services/launchGameService";
|
||||||
import * as emulatorSchema from "./schema/emulators";
|
import * as emulatorSchema from "./schema/emulators";
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { InstallJob } from "./jobs/install-job";
|
||||||
|
import { move } from "fs-extra";
|
||||||
|
|
||||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
.get('/emulators/automatic', async () =>
|
.get('/emulators/automatic', async () =>
|
||||||
|
|
@ -90,6 +93,46 @@ export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
}, {
|
}, {
|
||||||
response: z.array(z.string())
|
response: z.array(z.string())
|
||||||
})
|
})
|
||||||
|
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||||
|
{
|
||||||
|
if (taskQueue.hasActiveOfType(InstallJob))
|
||||||
|
{
|
||||||
|
return status("Forbidden", "Installation in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldDownloadPath = config.get('downloadPath');
|
||||||
|
if (!existsSync(oldDownloadPath))
|
||||||
|
{
|
||||||
|
return status("Not Found", "Old downlod path doesn't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDirEmpty (dirname: string)
|
||||||
|
{
|
||||||
|
const files = await fs.readdir(dirname);
|
||||||
|
return files.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = manualPath ?? drive;
|
||||||
|
|
||||||
|
if (!path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(path) && !isDirEmpty(path))
|
||||||
|
{
|
||||||
|
return status("Conflict", "New location alaready exists and is not empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
await move(oldDownloadPath, path);
|
||||||
|
config.set('downloadPath', manualPath);
|
||||||
|
return manualPath;
|
||||||
|
}, {
|
||||||
|
body: z.object({
|
||||||
|
manualPath: z.string().optional(),
|
||||||
|
drive: z.string().optional()
|
||||||
|
})
|
||||||
|
})
|
||||||
.get("/:id", async ({ params: { id } }) =>
|
.get("/:id", async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const value = config.get(id);
|
const value = config.get(id);
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,38 @@ import open from 'open';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { config, events } from "./app";
|
import { config, events } from "./app";
|
||||||
import { isSteamDeckGameMode } from "../utils";
|
import { isSteamDeck, openExternal } from "../utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import buildNotificationsStream from "./notifications";
|
import buildNotificationsStream from "./notifications";
|
||||||
|
import path, { dirname } from "node:path";
|
||||||
|
import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
||||||
|
import { getDevices, getDevicesCurated } from "./drives";
|
||||||
|
import getFolderSize from "get-folder-size";
|
||||||
|
import si from 'systeminformation';
|
||||||
|
|
||||||
// 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' })
|
||||||
.post('/show_keyboard', async () =>
|
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||||
{
|
{
|
||||||
if (isSteamDeckGameMode())
|
if (await isSteamDeck())
|
||||||
{
|
{
|
||||||
open('steam://open/keyboard');
|
const url = new URL('steam://open/keyboard');
|
||||||
|
if (XPosition) url.searchParams.set('XPosition', String(XPosition));
|
||||||
|
if (YPosition) url.searchParams.set('YPosition', String(YPosition));
|
||||||
|
if (Width) url.searchParams.set('Width', String(Width));
|
||||||
|
if (Height) url.searchParams.set('Height', String(Height));
|
||||||
|
open(url.href);
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
body: z.object({
|
||||||
|
XPosition: z.coerce.number().optional(),
|
||||||
|
YPosition: z.coerce.number().optional(),
|
||||||
|
Width: z.coerce.number().optional(),
|
||||||
|
Height: z.coerce.number().optional()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.get('/info', async () =>
|
.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,
|
||||||
|
|
@ -29,9 +43,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
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 }) =>
|
.get('/notifications', ({ set }) =>
|
||||||
|
|
@ -41,13 +52,105 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
set.headers['connection'] = 'keep-alive';
|
set.headers['connection'] = 'keep-alive';
|
||||||
return new Response(buildNotificationsStream());
|
return new Response(buildNotificationsStream());
|
||||||
})
|
})
|
||||||
|
.get('/info/battery', async () =>
|
||||||
|
{
|
||||||
|
return si.battery();
|
||||||
|
})
|
||||||
|
.get('/info/wifi', async () =>
|
||||||
|
{
|
||||||
|
return si.wifiConnections();
|
||||||
|
})
|
||||||
|
.get('/info/bluetooth', async () =>
|
||||||
|
{
|
||||||
|
return si.bluetoothDevices();
|
||||||
|
})
|
||||||
|
.get('/drives', async () =>
|
||||||
|
{
|
||||||
|
const drives = await getDevices();
|
||||||
|
return drives;
|
||||||
|
})
|
||||||
|
.get('/drives/download', async () =>
|
||||||
|
{
|
||||||
|
const drives = await getDevicesCurated();
|
||||||
|
const downloadsPath = config.get('downloadPath');
|
||||||
|
const currentDownloadsSize = await getFolderSize(downloadsPath);
|
||||||
|
let used = false;
|
||||||
|
const drivesDownload: DownloadsDrive[] = drives
|
||||||
|
.filter(d => !!d.mountPoint)
|
||||||
|
.map(d => ({ ...d, depth: d.mountPoint!.split(path.sep).length }))
|
||||||
|
.sort((a, b) => b.depth - a.depth)
|
||||||
|
.map(d =>
|
||||||
|
{
|
||||||
|
const drive: DownloadsDrive = {
|
||||||
|
device: d.device,
|
||||||
|
label: d.label,
|
||||||
|
mountPoint: path.join(d.mountPoint!, 'gameflow'),
|
||||||
|
isRemovable: d.isRemovable,
|
||||||
|
size: d.size,
|
||||||
|
used: d.used,
|
||||||
|
isCurrentlyUsed: false,
|
||||||
|
unusableReason: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!used && d.mountPoint && downloadsPath.startsWith(d.mountPoint))
|
||||||
|
{
|
||||||
|
drive.isCurrentlyUsed = true;
|
||||||
|
used = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drive.isCurrentlyUsed && currentDownloadsSize && drive.size - drive.used <= currentDownloadsSize.size)
|
||||||
|
{
|
||||||
|
drive.unusableReason = 'not_enough_space';
|
||||||
|
}
|
||||||
|
else if (drive.isCurrentlyUsed && downloadsPath === drive.mountPoint)
|
||||||
|
{
|
||||||
|
drive.unusableReason = 'already_used';
|
||||||
|
}
|
||||||
|
|
||||||
|
return drive;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
downloadsSize: currentDownloadsSize.size,
|
||||||
|
configPath: dirname(config.path),
|
||||||
|
drives: drivesDownload,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.put('/dirs', async ({ body: { dirname, name } }) =>
|
||||||
|
{
|
||||||
|
await fs.mkdir(path.join(dirname, name));
|
||||||
|
}, {
|
||||||
|
body: z.object({ dirname: z.string(), name: z.string() })
|
||||||
|
})
|
||||||
|
.get('/dirs', async ({ query: { path: startingPath } }) =>
|
||||||
|
{
|
||||||
|
const currentPath = startingPath ?? dirname(Bun.main);
|
||||||
|
const paths = await fs.readdir(currentPath, { withFileTypes: true });
|
||||||
|
return {
|
||||||
|
name: path.basename(currentPath),
|
||||||
|
parentPath: path.dirname(currentPath),
|
||||||
|
dirs: paths.sort((a, b) => (b.isDirectory() ? 1 : 0) - (a.isDirectory() ? 1 : 0)).map(p =>
|
||||||
|
({
|
||||||
|
name: p.name,
|
||||||
|
parentPath: p.parentPath,
|
||||||
|
isDirectory: p.isDirectory()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: z.object({ path: z.string().optional() }),
|
||||||
|
response: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
parentPath: z.string(),
|
||||||
|
dirs: z.array(DirSchema)
|
||||||
|
})
|
||||||
|
})
|
||||||
.post('/exit', () =>
|
.post('/exit', () =>
|
||||||
{
|
{
|
||||||
events.emit('exitapp');
|
events.emit('exitapp');
|
||||||
})
|
})
|
||||||
.post('/open', async ({ query: { url } }) =>
|
.post('/open', async ({ body: { url } }) =>
|
||||||
{
|
{
|
||||||
open(url);
|
await openExternal(url);
|
||||||
}, {
|
}, {
|
||||||
query: z.object({ url: z.url() })
|
body: z.object({ url: z.string() })
|
||||||
});
|
});
|
||||||
|
|
@ -46,6 +46,18 @@ export class TaskQueue
|
||||||
return this.activeQueue.length > 0;
|
return this.activeQueue.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasActiveOfType (type: any)
|
||||||
|
{
|
||||||
|
for (const entry of this.activeQueue)
|
||||||
|
{
|
||||||
|
if (entry.context.job instanceof type)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public waitForJob (id: string): Promise<void>
|
public waitForJob (id: string): Promise<void>
|
||||||
{
|
{
|
||||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { killBrowser, spawnBrowser } from './utils/browser-spawner';
|
||||||
import { BuildParams } from './utils/browser-params';
|
import { BuildParams } from './utils/browser-params';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { EventEmitter } from 'node:stream';
|
import { EventEmitter } from 'node:stream';
|
||||||
|
import { config } from './api/app';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
export default async function init (events: EventEmitter, forceBrowser: boolean)
|
export default async function init (events: EventEmitter, forceBrowser: boolean)
|
||||||
{
|
{
|
||||||
|
|
@ -51,7 +53,7 @@ async function runWebview (events: EventEmitter)
|
||||||
|
|
||||||
async function runBrowser (events: EventEmitter)
|
async function runBrowser (events: EventEmitter)
|
||||||
{
|
{
|
||||||
const browserParams = await BuildParams();
|
const browserParams = await BuildParams({ configPath: dirname(config.path) });
|
||||||
if (!browserParams)
|
if (!browserParams)
|
||||||
{
|
{
|
||||||
console.error("Could not find valid browser");
|
console.error("Could not find valid browser");
|
||||||
|
|
@ -68,6 +70,7 @@ async function runBrowser (events: EventEmitter)
|
||||||
detached: false,
|
detached: false,
|
||||||
execPath: browserParams.browser.path,
|
execPath: browserParams.browser.path,
|
||||||
source: browserParams.browser.source,
|
source: browserParams.browser.source,
|
||||||
|
configPath: dirname(config.path),
|
||||||
ipc (message)
|
ipc (message)
|
||||||
{
|
{
|
||||||
console.log(message);
|
console.log(message);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { SERVER_PORT } from "../shared/constants";
|
import { SERVER_PORT } from "../shared/constants";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { host } from "./utils";
|
|
||||||
import appInfo from '../../package.json';
|
import appInfo from '../../package.json';
|
||||||
|
import { host } from "./utils/host";
|
||||||
|
|
||||||
export function RunBunServer ()
|
export function RunBunServer ()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
4
src/bun/types/types.d.ts
vendored
4
src/bun/types/types.d.ts
vendored
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
declare const IS_BINARY: string;
|
declare const IS_BINARY: string;
|
||||||
|
|
||||||
export type ActiveGame = {
|
export type ActiveGame = {
|
||||||
pid?: number;
|
process?: ChildProcess;
|
||||||
gameId: number;
|
gameId: number;
|
||||||
name: string;
|
name: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
|
|
||||||
import { networkInterfaces } from 'node:os';
|
import { $ } from 'bun';
|
||||||
|
|
||||||
const localIp = Object.values(networkInterfaces())
|
|
||||||
.flat()
|
|
||||||
.find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost';
|
|
||||||
|
|
||||||
export const host = process.env.PUBLIC_ACCESS ? localIp : 'localhost';
|
|
||||||
|
|
||||||
export function checkRunning (pid: number)
|
export function checkRunning (pid: number)
|
||||||
{
|
{
|
||||||
|
|
@ -28,3 +22,37 @@ export function isSteamDeckGameMode ()
|
||||||
{
|
{
|
||||||
return !!Bun.env.SteamDeck;
|
return !!Bun.env.SteamDeck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isSteamDeck ()
|
||||||
|
{
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const productName = await Bun.file("/sys/class/dmi/id/product_name").text();
|
||||||
|
const isSteamDeck = ["Jupiter", "Galileo"].includes(productName.trim());
|
||||||
|
return isSteamDeck;
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
return isSteamDeckGameMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openExternal (target: string)
|
||||||
|
{
|
||||||
|
if (process.platform === "linux")
|
||||||
|
{
|
||||||
|
return $`xdg-open ${target}`.throws(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32")
|
||||||
|
{
|
||||||
|
return $`cmd /c start ${target}`.throws(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "darwin")
|
||||||
|
{
|
||||||
|
return $`open ${target}`.throws(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { SERVER_URL } from "../../shared/constants";
|
import { SERVER_URL } from "../../shared/constants";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path, { dirname } from 'node:path';
|
import path from 'node:path';
|
||||||
import { getBrowserPath } from "./get-browser";
|
import { getBrowserPath } from "./get-browser";
|
||||||
import { host, isSteamDeckGameMode } from "../utils";
|
import { isSteamDeckGameMode } from "../utils";
|
||||||
import { config } from "../api/app";
|
import { config } from "../api/app";
|
||||||
|
import { ensureDir } from 'fs-extra';
|
||||||
|
import { host } from "./host";
|
||||||
|
|
||||||
export async function BuildParams ()
|
export async function BuildParams (data: { configPath: string; })
|
||||||
{
|
{
|
||||||
const validBrowser = await getBrowserPath({
|
const validBrowser = await getBrowserPath({
|
||||||
browserOrder: ['chrome', 'chromium']
|
browserOrder: ['chrome', 'chromium']
|
||||||
|
|
@ -28,15 +30,19 @@ export async function BuildParams ()
|
||||||
const isEdge = validBrowser.path.toLowerCase().includes('edge') || validBrowser.path.toLowerCase().includes('msedge');
|
const isEdge = validBrowser.path.toLowerCase().includes('edge') || validBrowser.path.toLowerCase().includes('msedge');
|
||||||
console.log(`[Browser] Detected: ${validBrowser.type} from ${validBrowser.source} - ${isEdge ? 'Edge' : 'Chrome/Chromium'}`);
|
console.log(`[Browser] Detected: ${validBrowser.type} from ${validBrowser.source} - ${isEdge ? 'Edge' : 'Chrome/Chromium'}`);
|
||||||
|
|
||||||
|
const dataPath = path.join(data.configPath, 'browser-data');
|
||||||
|
await ensureDir(dataPath);
|
||||||
|
|
||||||
args.push(`--app=${SERVER_URL(host)}`);
|
args.push(`--app=${SERVER_URL(host)}`);
|
||||||
args.push(`--app-id=gameflow`);
|
args.push(`--app-id=gameflow`);
|
||||||
args.push(`--force-app-mode`);
|
args.push(`--force-app-mode`);
|
||||||
args.push('--no-default-browser-check');
|
args.push('--no-default-browser-check');
|
||||||
|
args.push('--new-instance');
|
||||||
args.push('--no-first-run');
|
args.push('--no-first-run');
|
||||||
args.push('--disable-infobars');
|
args.push('--disable-infobars');
|
||||||
args.push("--disable-extensions");
|
args.push("--disable-extensions");
|
||||||
args.push("--disable-plugins");
|
args.push("--disable-plugins");
|
||||||
args.push(`--user-data-dir=${path.join(dirname(config.path), 'browser-data')}`);
|
args.push(`--user-data-dir=${dataPath}`);
|
||||||
args.push('--disable-sync'); //Disable syncing to a Google account
|
args.push('--disable-sync'); //Disable syncing to a Google account
|
||||||
args.push('--disable-sync-preferences');
|
args.push('--disable-sync-preferences');
|
||||||
args.push('--disable-component-update');
|
args.push('--disable-component-update');
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
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';
|
import os from 'node:os';
|
||||||
|
|
||||||
export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
|
export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
|
||||||
|
|
@ -25,6 +22,7 @@ interface SpawnBrowserOptions
|
||||||
detached?: boolean;
|
detached?: boolean;
|
||||||
execPath: string; // Required: browser executable path from get-browser.ts
|
execPath: string; // Required: browser executable path from get-browser.ts
|
||||||
source: RunBrowserSource; // How the browser was discovered (running, system, or flatpak)
|
source: RunBrowserSource; // How the browser was discovered (running, system, or flatpak)
|
||||||
|
configPath: string;
|
||||||
onExit?: () => void; // Called when the browser exists duh
|
onExit?: () => void; // Called when the browser exists duh
|
||||||
ipc?: (message: string) => void;
|
ipc?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +67,8 @@ export async function spawnBrowser ({
|
||||||
execPath,
|
execPath,
|
||||||
source,
|
source,
|
||||||
onExit,
|
onExit,
|
||||||
ipc
|
ipc,
|
||||||
|
configPath
|
||||||
}: SpawnBrowserOptions): Promise<Subprocess>
|
}: SpawnBrowserOptions): Promise<Subprocess>
|
||||||
{
|
{
|
||||||
// Configuration for both Flatpak and Native
|
// Configuration for both Flatpak and Native
|
||||||
|
|
@ -117,6 +116,7 @@ export async function spawnBrowser ({
|
||||||
"--branch=stable",
|
"--branch=stable",
|
||||||
`--arch=${process.arch === "x64" ? "x86_64" : process.arch}`, // map node arch to flatpak arch
|
`--arch=${process.arch === "x64" ? "x86_64" : process.arch}`, // map node arch to flatpak arch
|
||||||
`--command=${target.internalCmd}`,
|
`--command=${target.internalCmd}`,
|
||||||
|
`--filesystem=${configPath}`, // we must allw it to use our own config path to save profile data
|
||||||
"--file-forwarding",
|
"--file-forwarding",
|
||||||
...envFlags // Inject env vars here
|
...envFlags // Inject env vars here
|
||||||
];
|
];
|
||||||
|
|
|
||||||
7
src/bun/utils/host.ts
Normal file
7
src/bun/utils/host.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { networkInterfaces } from "node:os";
|
||||||
|
|
||||||
|
const localIp = Object.values(networkInterfaces())
|
||||||
|
.flat()
|
||||||
|
.find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost';
|
||||||
|
|
||||||
|
export const host = process.env.PUBLIC_ACCESS ? localIp : 'localhost';
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SERVER_URL } from "@/shared/constants";
|
import { SERVER_URL } from "@/shared/constants";
|
||||||
import Webview from "@rcompat/webview";
|
import Webview from "@rcompat/webview";
|
||||||
import { host } from "../utils";
|
import { host } from "../utils/host";
|
||||||
|
|
||||||
export default function (webview: Webview)
|
export default function (webview: Webview)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import { AutoFocus } from './AutoFocus';
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||||
import { Router } from '..';
|
import { Router } from '..';
|
||||||
import { PopSource } from '../scripts/spatialNavigation';
|
import { PopSource } from '../scripts/spatialNavigation';
|
||||||
|
import { GameListFilterType } from '@/shared/constants';
|
||||||
|
|
||||||
export interface CollectionsDetailParams
|
export interface CollectionsDetailParams
|
||||||
{
|
{
|
||||||
id?: string;
|
id?: string;
|
||||||
setBackground: (url: string) => void;
|
setBackground: (url: string) => void;
|
||||||
filters: GameListFilter;
|
filters?: GameListFilterType;
|
||||||
headerTitle?: JSX.Element;
|
headerTitle?: JSX.Element;
|
||||||
title?: JSX.Element;
|
title?: JSX.Element;
|
||||||
footer?: JSX.Element;
|
footer?: JSX.Element;
|
||||||
|
|
@ -32,7 +33,7 @@ function HandleGoBack ()
|
||||||
|
|
||||||
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 ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
|
||||||
const { ref, focusSelf } = useFocusable({
|
const { ref, focusSelf } = useFocusable({
|
||||||
focusKey,
|
focusKey,
|
||||||
preferredChildFocusKey: `${focusKey}-list`,
|
preferredChildFocusKey: `${focusKey}-list`,
|
||||||
|
|
@ -51,7 +52,14 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
<div className="h-fit w-full px-6 pt-4 pb-32">
|
<div className="h-fit w-full px-6 pt-4 pb-32">
|
||||||
{data.title}
|
{data.title}
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<GameList grid setBackground={data.setBackground} filters={data.filters} id={`${focusKey}-list`}></GameList>
|
<GameList
|
||||||
|
grid
|
||||||
|
setBackground={data.setBackground}
|
||||||
|
filters={data.filters}
|
||||||
|
onFocus={(node) => node.scrollIntoView({ block: 'center', behavior: 'smooth' })}
|
||||||
|
id={`${focusKey}-list`}>
|
||||||
|
|
||||||
|
</GameList>
|
||||||
<AutoFocus focus={focusSelf} />
|
<AutoFocus focus={focusSelf} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,19 @@ import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedi
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { createContext, JSX, useContext, useEffect } from "react";
|
import { createContext, JSX, useContext, useEffect } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useEventListener } from "usehooks-ts";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||||
|
|
||||||
const ContextDialogContext = createContext({} as {
|
const ContextDialogContext = createContext({} as {
|
||||||
close: () => void,
|
close: () => void,
|
||||||
id: string;
|
id: string;
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||||
{
|
{
|
||||||
const context = useContext(ContextDialogContext);
|
const context = useContext(ContextDialogContext);
|
||||||
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
|
return <ul className={twMerge("list", data.className)}>
|
||||||
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||||
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
|
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
|
||||||
</ul>;
|
</ul>;
|
||||||
}
|
}
|
||||||
|
|
@ -29,30 +28,37 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
data.onFocus?.();
|
data.onFocus?.();
|
||||||
};
|
};
|
||||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||||
const { ref, focused, focusSelf } = useFocusable({
|
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||||
focusKey: `${context.id}-list-option-${data.id}`,
|
focusKey: `${context.id}-list-option-${data.id}`,
|
||||||
onEnterPress: handleAction,
|
onEnterPress: handleAction,
|
||||||
onFocus: handleFocus
|
onFocus: handleFocus,
|
||||||
|
trackChildren: typeof data.content !== 'string'
|
||||||
});
|
});
|
||||||
const colors = {
|
const colors = {
|
||||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
|
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
|
||||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
|
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
|
||||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
|
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
|
||||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
|
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
|
||||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
|
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
|
||||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
|
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
|
||||||
};
|
};
|
||||||
|
if (data.shortcuts)
|
||||||
|
{
|
||||||
|
useShortcuts(focusKey, () => data.shortcuts!, [data.shortcuts]);
|
||||||
|
}
|
||||||
return <li ref={ref}
|
return <li ref={ref}
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
className={
|
className={
|
||||||
twMerge("flex cursor-pointer")}>
|
twMerge("flex cursor-pointer")}>
|
||||||
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
|
<FocusContext value={focusKey}>
|
||||||
|
<div className={twMerge("flex w-full h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
||||||
colors[data.type],
|
colors[data.type],
|
||||||
classNames({ "font-semibold": focused }),
|
classNames({ "font-semibold": focused || hasFocusedChild }),
|
||||||
data.className)}>
|
data.className)}>
|
||||||
{data.icon}
|
{data.icon}
|
||||||
{data.content}
|
{data.content}
|
||||||
</p>
|
</div>
|
||||||
|
</FocusContext>
|
||||||
</li>;
|
</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,11 +69,23 @@ export interface DialogEntry
|
||||||
icon?: string | JSX.Element;
|
icon?: string | JSX.Element;
|
||||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
||||||
|
shortcuts?: Shortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
|
export function ContextDialog (data: {
|
||||||
|
id: string,
|
||||||
|
children: any | any[],
|
||||||
|
open: boolean, close: () => void;
|
||||||
|
className?: string;
|
||||||
|
preferredChildFocusKey?: string;
|
||||||
|
})
|
||||||
{
|
{
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
|
focusable: data.open,
|
||||||
|
focusKey: `${data.id}-context-dialog`,
|
||||||
|
isFocusBoundary: true,
|
||||||
|
preferredChildFocusKey: data.preferredChildFocusKey
|
||||||
|
});
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if (data.open)
|
if (data.open)
|
||||||
|
|
@ -76,14 +94,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
|
||||||
}
|
}
|
||||||
}, [data.open]);
|
}, [data.open]);
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{
|
useShortcuts(focusKey, () => data.open ? [{
|
||||||
label: "Close",
|
label: "Close",
|
||||||
button: GamePadButtonCode.B,
|
button: GamePadButtonCode.B,
|
||||||
action: () =>
|
action: () =>
|
||||||
{
|
{
|
||||||
data.close();
|
data.close();
|
||||||
}
|
}
|
||||||
}], []);
|
}] : [], [data.open]);
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -96,7 +114,11 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||||
<div
|
<div
|
||||||
className={twMerge("bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto", data.open ? "animate-scale-delayed" : "opacity-0")}
|
className={twMerge(
|
||||||
|
"bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto",
|
||||||
|
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||||
|
data.className)
|
||||||
|
}
|
||||||
style={{ backdropFilter: 'blur(24px)' }}
|
style={{ backdropFilter: 'blur(24px)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
286
src/mainview/components/FilePicker.tsx
Normal file
286
src/mainview/components/FilePicker.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
|
||||||
|
import { systemApi } from "../scripts/clientApi";
|
||||||
|
import { createContext, useContext, useRef, useState } from "react";
|
||||||
|
import path from "pathe";
|
||||||
|
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
|
||||||
|
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
import { DirType, Drive } from "@/shared/constants";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||||
|
import SvgIcon from "./SvgIcon";
|
||||||
|
import { Button } from "./options/Button";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { drivesQuery, filesQuery } from "../scripts/queries";
|
||||||
|
|
||||||
|
const FilePickerContext = createContext<{
|
||||||
|
allowNewFolderCreation: boolean;
|
||||||
|
isDirectoryPicker: boolean;
|
||||||
|
setCurrentPath: (path: string) => void;
|
||||||
|
currentPath: string | undefined,
|
||||||
|
startingPath: string | undefined;
|
||||||
|
refetchFiles: () => void;
|
||||||
|
drives: Drive[],
|
||||||
|
activeDrive: Drive | undefined;
|
||||||
|
}>({} as any);
|
||||||
|
|
||||||
|
function List (data: {
|
||||||
|
id: string,
|
||||||
|
parentPath: string,
|
||||||
|
dirs: DirType[],
|
||||||
|
select: (path: string) => void;
|
||||||
|
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext);
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` });
|
||||||
|
const handleReturn = () => setCurrentPath(data.parentPath);
|
||||||
|
useShortcuts(focusKey, () => [{ label: "Directoy Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]);
|
||||||
|
return <div ref={ref}>
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
<ContextList showCloseButton={false}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
action: handleReturn,
|
||||||
|
id: `${data.id}...`,
|
||||||
|
type: 'primary',
|
||||||
|
content: <div className="flex justify-between w-full items-center">...<SvgIcon className="md:size-8 sm:size-6" icon={'steamdeck_button_l1_outline'} /> </div>,
|
||||||
|
icon: <FolderOutput />,
|
||||||
|
shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }]
|
||||||
|
},
|
||||||
|
...data.dirs.map(f =>
|
||||||
|
{
|
||||||
|
const fullPath = path.join(f.parentPath, f.name);
|
||||||
|
const isDefaultPath = fullPath === startingPath;
|
||||||
|
let icon = <Folder />;
|
||||||
|
if (isDefaultPath)
|
||||||
|
{
|
||||||
|
icon = <FolderInput />;
|
||||||
|
} else if (!f.isDirectory)
|
||||||
|
{
|
||||||
|
icon = <></>;
|
||||||
|
}
|
||||||
|
const shortcuts: Shortcut[] = [];
|
||||||
|
if (f.isDirectory)
|
||||||
|
{
|
||||||
|
shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) });
|
||||||
|
if (isDirectoryPicker)
|
||||||
|
shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) });
|
||||||
|
}
|
||||||
|
const entry: DialogEntry = {
|
||||||
|
content: f.name,
|
||||||
|
id: `${data.id}-${f.name}`,
|
||||||
|
type: 'primary',
|
||||||
|
icon,
|
||||||
|
shortcuts
|
||||||
|
};
|
||||||
|
return entry;
|
||||||
|
}), ...(allowNewFolderCreation && currentPath ? [{
|
||||||
|
content: <NewFolderOption id={`${data.id}-new-folder-content`} dirname={currentPath} />,
|
||||||
|
id: `${data.id}-new-folder`,
|
||||||
|
type: 'primary'
|
||||||
|
} satisfies DialogEntry] : [])]
|
||||||
|
} />
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; })
|
||||||
|
{
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { ref, focused, focusSelf } = useFocusable({
|
||||||
|
focusKey: data.id,
|
||||||
|
onEnterPress: () => inputRef.current?.focus(),
|
||||||
|
onBlur: () => inputRef.current?.blur(),
|
||||||
|
});
|
||||||
|
const handleFocus = () =>
|
||||||
|
{
|
||||||
|
focusSelf();
|
||||||
|
systemApi.api.system.show_keyboard.post();
|
||||||
|
};
|
||||||
|
return <div className={data.className} ref={ref}>
|
||||||
|
<input ref={inputRef}
|
||||||
|
className={twMerge("input rounded-xl focus:ring-base-content w-full", classNames({ "ring-4 ring-accent": focused }))}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
value={data.name}
|
||||||
|
placeholder="New Folder"
|
||||||
|
onChange={e => data.setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewFolderOption (data: { id: string, dirname: string; })
|
||||||
|
{
|
||||||
|
const { refetchFiles } = useContext(FilePickerContext);
|
||||||
|
const [name, setName] = useState<string | undefined>();
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationKey: ['create', 'folder', data.id], mutationFn: async () =>
|
||||||
|
{
|
||||||
|
if (!name) return;
|
||||||
|
const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname });
|
||||||
|
if (error) throw error.value;
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||||
|
onSuccess: (d, v, r, cx) =>
|
||||||
|
{
|
||||||
|
toast.success(`Folder ${name} created`);
|
||||||
|
refetchFiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return <div className="flex gap-2 grow -ml-2">
|
||||||
|
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
|
||||||
|
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionButtons (data: {
|
||||||
|
id: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
showConfirm: boolean;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
|
||||||
|
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
|
||||||
|
<Button className="p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriveElement (data: { id: string, isActive: boolean, label: string; onSelect: () => void; isRemovable: boolean; })
|
||||||
|
{
|
||||||
|
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
|
||||||
|
return <li ref={ref} onClick={data.onSelect} className={twMerge(
|
||||||
|
"flex bg-base-200 text-base-content rounded-full gap-2 items-center p-2 overflow-hidden max-w-xs",
|
||||||
|
classNames({
|
||||||
|
"bg-primary text-primary-content": data.isActive,
|
||||||
|
"ring-7 ring-base-content": focused
|
||||||
|
})
|
||||||
|
)}>
|
||||||
|
{data.isRemovable ? <Usb /> : <HardDrive />}
|
||||||
|
{data.label}
|
||||||
|
</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Drives (data: {
|
||||||
|
id: string,
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { drives, activeDrive } = useContext(FilePickerContext);
|
||||||
|
const { focusKey, ref } = useFocusable({
|
||||||
|
focusKey: data.id,
|
||||||
|
preferredChildFocusKey: activeDrive?.mountPoint ?? undefined,
|
||||||
|
saveLastFocusedChild: false,
|
||||||
|
autoRestoreFocus: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return <ul className="flex flex-col gap-2" ref={ref} >
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
{drives?.filter(d => d.mountPoint)
|
||||||
|
.sort((a, b) => b.mountPoint!.length - a.mountPoint!.length)
|
||||||
|
.map(d =>
|
||||||
|
<DriveElement isRemovable={d.isRemovable} onSelect={() => data.onSelect(d.mountPoint!)} id={d.mountPoint!} isActive={activeDrive?.mountPoint === d.mountPoint} label={d.label} />
|
||||||
|
)}
|
||||||
|
</FocusContext>
|
||||||
|
</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListWithDrives (data: {
|
||||||
|
id: string,
|
||||||
|
files: DirType[],
|
||||||
|
onSelect: (path: string) => void,
|
||||||
|
parentPath: string;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { setCurrentPath, isDirectoryPicker } = useContext(FilePickerContext);
|
||||||
|
const { focusKey, ref } = useFocusable({
|
||||||
|
focusKey: `main-${data.id}`,
|
||||||
|
preferredChildFocusKey: `list-${data.id}`
|
||||||
|
});
|
||||||
|
return <div ref={ref} className="flex grow min-h-0 gap-2">
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Drives onSelect={p => setCurrentPath(p)} id={`drives-${data.id}`} />
|
||||||
|
<div className="divider divider-horizontal m-1"></div>
|
||||||
|
</div>
|
||||||
|
<div className="divider divider-horizontal m-0"></div>
|
||||||
|
<div className="overflow-y-auto w-full">
|
||||||
|
<List
|
||||||
|
id={`list-${data.id}`}
|
||||||
|
dirs={data.files.filter(d =>
|
||||||
|
{
|
||||||
|
if (isDirectoryPicker && !d.isDirectory)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})} parentPath={data.parentPath} select={data.onSelect} />
|
||||||
|
</div>
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilePicker (data: {
|
||||||
|
id: string;
|
||||||
|
startingPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
isDirectoryPicker?: boolean;
|
||||||
|
cancel: () => void;
|
||||||
|
allowNewFolderCreation?: boolean;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||||
|
|
||||||
|
const { data: files, refetch: refetchFiles } = useQuery(filesQuery(currentPath, data.id));
|
||||||
|
const { data: drives } = useQuery(drivesQuery);
|
||||||
|
|
||||||
|
const fullPath = files ? path.join(files.parentPath, files.name) : '';
|
||||||
|
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
|
||||||
|
const activeDriveMount = activeDrive?.mountPoint;
|
||||||
|
const fullPathElements = activeDrive?.label ?
|
||||||
|
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
|
||||||
|
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
|
||||||
|
|
||||||
|
return <div className="flex flex-col h-full max-h-full gap-3">
|
||||||
|
<FilePickerContext value={{
|
||||||
|
setCurrentPath,
|
||||||
|
currentPath,
|
||||||
|
isDirectoryPicker: data.isDirectoryPicker ?? false,
|
||||||
|
refetchFiles,
|
||||||
|
startingPath: data.startingPath,
|
||||||
|
allowNewFolderCreation: data.allowNewFolderCreation ?? false,
|
||||||
|
drives: drives ?? [],
|
||||||
|
activeDrive
|
||||||
|
}}>
|
||||||
|
{!!fullPath &&
|
||||||
|
<div className="breadcrumbs flex items-center text-sm min-h-12 max-h-12 h-12 px-4 py-2 overflow-hidden bg-base-300 text-base-content rounded-full">
|
||||||
|
<ul>
|
||||||
|
{fullPathElements.map((p, i) => <li>
|
||||||
|
<a onClick={() =>
|
||||||
|
setCurrentPath(path.join(...fullPath.slice(-i)))
|
||||||
|
}>{p}</a>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<ListWithDrives
|
||||||
|
id={data.id}
|
||||||
|
files={files?.dirs ?? []}
|
||||||
|
onSelect={data.onSelect}
|
||||||
|
parentPath={files?.parentPath ?? ''}
|
||||||
|
/>
|
||||||
|
<OptionButtons
|
||||||
|
showConfirm={!!data.isDirectoryPicker}
|
||||||
|
onCancel={data.cancel}
|
||||||
|
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
|
||||||
|
id={data.id} />
|
||||||
|
</FilePickerContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -79,11 +79,11 @@ export default function GameCard (data: GameCardParams)
|
||||||
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 gap-2">
|
||||||
{data.badges?.map((b, i) =>
|
{data.badges?.map((b, i) =>
|
||||||
<div key={i}
|
<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 last:mr-4 transition-colors",
|
||||||
classNames({ "bg-primary text-primary-content": focused }))}
|
classNames({ "bg-primary text-primary-content": focused }))}
|
||||||
>
|
>
|
||||||
{b}
|
{b}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
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, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||||
import { 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";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
|
|
||||||
export interface GameListFilter
|
|
||||||
{
|
|
||||||
platformId?: number;
|
|
||||||
collectionId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameListParams
|
export interface GameListParams
|
||||||
{
|
{
|
||||||
id: string,
|
id: string,
|
||||||
filters?: GameListFilter,
|
filters?: GameListFilterType,
|
||||||
grid?: boolean,
|
grid?: boolean,
|
||||||
setBackground?: (url: string) => void;
|
setBackground?: (url: string) => void;
|
||||||
onGameSelect?: (id: FrontEndId) => void;
|
onGameSelect?: (id: FrontEndId) => void;
|
||||||
|
|
@ -29,10 +23,7 @@ export function GameList (data: GameListParams)
|
||||||
const games = useSuspenseQuery({
|
const games = useSuspenseQuery({
|
||||||
queryKey: ['games', data.filters ?? 'all'],
|
queryKey: ['games', data.filters ?? 'all'],
|
||||||
queryFn: () => rommApi.api.romm.games.get({
|
queryFn: () => rommApi.api.romm.games.get({
|
||||||
query: {
|
query: data.filters
|
||||||
platform_id: data.filters?.platformId,
|
|
||||||
collection_id: data.filters?.collectionId
|
|
||||||
}
|
|
||||||
}).then(d => d.data)
|
}).then(d => d.data)
|
||||||
});
|
});
|
||||||
const navigator = useNavigate();
|
const navigator = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
|
BatteryCharging,
|
||||||
BatteryFull,
|
BatteryFull,
|
||||||
|
BatteryLow,
|
||||||
|
BatteryMedium,
|
||||||
|
BatteryWarning,
|
||||||
Bell,
|
Bell,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -16,14 +20,18 @@ import
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
Wifi,
|
Wifi,
|
||||||
|
WifiHigh,
|
||||||
|
WifiLow,
|
||||||
|
WifiZero,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { RPC_URL } from "../../shared/constants";
|
||||||
import { JSX } from "react";
|
import { JSX, useEffect, useRef } from "react";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
import { SaveSource } from "../scripts/spatialNavigation";
|
||||||
|
import { systemApi } from "../scripts/clientApi";
|
||||||
|
|
||||||
function HeaderAvatar (data: {
|
function HeaderAvatar (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -104,11 +112,128 @@ export interface HeaderAccount
|
||||||
action?: () => void;
|
action?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationStatus ()
|
||||||
|
{
|
||||||
|
const hasUnread = false;
|
||||||
|
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
|
||||||
|
<Bell className="w-6 h-6" />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClockStatus ()
|
||||||
|
{
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
function update ()
|
||||||
|
{
|
||||||
|
if (ref.current)
|
||||||
|
{
|
||||||
|
ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update immediately
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Wait until next minute boundary
|
||||||
|
const now = new Date();
|
||||||
|
const msUntilNextMinute =
|
||||||
|
(60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||||
|
|
||||||
|
const timeout = setTimeout(() =>
|
||||||
|
{
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Then update every minute
|
||||||
|
const interval = setInterval(update, 60_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, msUntilNextMinute);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div className="flex gap-3"><span ref={ref}></span><Clock /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BluetoothStatus ()
|
||||||
|
{
|
||||||
|
const { data: bluetooth } = useQuery({
|
||||||
|
queryKey: ['wifi'],
|
||||||
|
queryFn: () => systemApi.api.system.info.bluetooth.get().then(d => d.data),
|
||||||
|
refetchInterval: 3000
|
||||||
|
});
|
||||||
|
return bluetooth && bluetooth.find(b => b.connected) && <div>
|
||||||
|
<Bluetooth className="w-6 h-6" />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WiFiStatus ()
|
||||||
|
{
|
||||||
|
const { data: wifi } = useQuery({
|
||||||
|
queryKey: ['wifi'],
|
||||||
|
queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data),
|
||||||
|
refetchInterval: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
{wifi?.map(w =>
|
||||||
|
{
|
||||||
|
const className = "w-6 h-6";
|
||||||
|
let icon = <Wifi className={className} />;
|
||||||
|
if (w.signalLevel >= -60)
|
||||||
|
icon = <Wifi className={className} />;
|
||||||
|
else if (w.signalLevel >= -70)
|
||||||
|
icon = <WifiHigh className={className} />;
|
||||||
|
else if (w.signalLevel >= -80)
|
||||||
|
icon = <WifiLow className={className} />;
|
||||||
|
else if (w.signalLevel >= -90)
|
||||||
|
icon = <WifiZero className={className} />;
|
||||||
|
|
||||||
|
return <div className="tooltip" data-tip={w.signalLevel}>
|
||||||
|
{icon}
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatteryStatus ()
|
||||||
|
{
|
||||||
|
const { data: battery } = useQuery({
|
||||||
|
queryKey: ['battery'],
|
||||||
|
queryFn: () => systemApi.api.system.info.battery.get().then(d => d.data),
|
||||||
|
refetchInterval: 3000
|
||||||
|
});
|
||||||
|
const batteryClassName = "w-6 h-6";
|
||||||
|
let batteryIcon = <BatteryFull className={batteryClassName} />;
|
||||||
|
if (battery?.isCharging || battery?.acConnected)
|
||||||
|
{
|
||||||
|
batteryIcon = <BatteryCharging className={batteryClassName} />;
|
||||||
|
} else if (battery?.percent)
|
||||||
|
{
|
||||||
|
if (battery.percent < 5)
|
||||||
|
{
|
||||||
|
batteryIcon = <BatteryWarning className={batteryClassName} />;
|
||||||
|
}
|
||||||
|
else if (battery.percent < 15)
|
||||||
|
{
|
||||||
|
batteryIcon = <BatteryLow className={batteryClassName} />;
|
||||||
|
} else if (battery.percent < 50)
|
||||||
|
{
|
||||||
|
batteryIcon = <BatteryMedium className={batteryClassName} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <div className="flex gap-2 items-center">
|
||||||
|
{batteryIcon}
|
||||||
|
<span className="font-semibold">{battery?.percent} %</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const rommOnline = useQuery({
|
const rommOnline = useQuery({
|
||||||
...statsApiStatsGetOptions(),
|
...statsApiStatsGetOptions(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
|
|
@ -161,18 +286,12 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
||||||
{data.title}
|
{data.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text drop-shadow-sm">
|
<div className="flex items-center gap-2 text drop-shadow-sm">
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5 items-center">
|
||||||
<Clock />
|
<ClockStatus />
|
||||||
<Wifi className="w-6 h-6" />
|
<WiFiStatus />
|
||||||
<Bluetooth className="w-6 h-6" />
|
<BluetoothStatus />
|
||||||
<div className="indicator">
|
<NotificationStatus />
|
||||||
<span className="indicator-item status status-error"></span>
|
<BatteryStatus />
|
||||||
<Bell className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<BatteryFull className="w-6 h-6" />
|
|
||||||
<span className="font-semibold">100%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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";
|
import { SaveSource } from "../scripts/spatialNavigation";
|
||||||
|
import { JSX } from "react";
|
||||||
|
import { HardDrive } from "lucide-react";
|
||||||
|
|
||||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
|
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
|
||||||
{
|
{
|
||||||
|
|
@ -29,22 +31,26 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
||||||
className={data.className}
|
className={data.className}
|
||||||
onGameFocus={(id, node) => data.onFocus?.(node)}
|
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) =>
|
||||||
|
{
|
||||||
|
const badges: JSX.Element[] = [];
|
||||||
|
badges.push(<span className="flex items-center justify-center size-6 m-1 text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||||
|
if (g.hasLocal)
|
||||||
|
badges.push(<HardDrive className="size-8 m-1" />);
|
||||||
|
const entry: GameMetaExtra = {
|
||||||
id: g.slug,
|
id: g.slug,
|
||||||
focusKey: g.slug,
|
focusKey: g.slug,
|
||||||
title: g.name,
|
title: g.name,
|
||||||
subtitle: g.family_name ?? "",
|
subtitle: g.family_name ?? "",
|
||||||
previewUrl: "",
|
previewUrl: "",
|
||||||
badges: [(<span className="text-lg font-bold p-2 rounded-full">
|
badges,
|
||||||
{g.game_count}
|
|
||||||
</span>)],
|
|
||||||
onFocus: () => data.setBackground(
|
onFocus: () => data.setBackground(
|
||||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||||
),
|
),
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
{
|
{
|
||||||
SaveSource('game-list');
|
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.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||||
},
|
},
|
||||||
preview:
|
preview:
|
||||||
({ focused }) => <div
|
({ focused }) => <div
|
||||||
|
|
@ -64,7 +70,9 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
||||||
></img>
|
></img>
|
||||||
</div>
|
</div>
|
||||||
,
|
,
|
||||||
} satisfies GameMetaExtra))}
|
};
|
||||||
|
return entry;
|
||||||
|
})}
|
||||||
onSelectGame={(id) =>
|
onSelectGame={(id) =>
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ const iconMap: Record<GamePadButtonCode, IconType> = {
|
||||||
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 z-1000">
|
||||||
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
|
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
|
||||||
key={s.button}
|
key={s.button}
|
||||||
id={`shortcut-${s.button}`}
|
id={`shortcut-${s.button}`}
|
||||||
onClick={e => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
||||||
icon={iconMap[s.button]}
|
icon={iconMap[s.button]}
|
||||||
label={s.label} />
|
label={s.label} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,38 @@ import
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
|
|
||||||
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
export function Button (data: {
|
||||||
|
id: string,
|
||||||
|
children?: any,
|
||||||
|
className?: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
type: "reset" | "button" | "submit" | undefined;
|
||||||
|
shortcutLabel?: string;
|
||||||
|
focusClassName?: string;
|
||||||
|
} & InteractParams & FocusParams)
|
||||||
{
|
{
|
||||||
const { ref, focused } = useFocusable({
|
const { ref, focused, focusKey } = useFocusable({
|
||||||
focusKey: data.id,
|
focusKey: data.id,
|
||||||
onEnterPress: data.onAction,
|
onEnterPress: data.onAction,
|
||||||
onFocus: data.onFocus,
|
onFocus: data.onFocus,
|
||||||
focusable: !data.disabled
|
focusable: !data.disabled
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.shortcutLabel)
|
||||||
|
{
|
||||||
|
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
||||||
|
}
|
||||||
|
|
||||||
return <button
|
return <button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={data.onAction}
|
onClick={data.onAction}
|
||||||
disabled={data.disabled}
|
disabled={data.disabled}
|
||||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
|
||||||
"btn-accent": focused
|
focused ? data.focusClassName : undefined,
|
||||||
|
classNames({
|
||||||
|
"btn-accent": focused,
|
||||||
}, data.className))}
|
}, data.className))}
|
||||||
type={data.type}
|
type={data.type}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
32
src/mainview/components/options/DownloadDirectoryOption.tsx
Normal file
32
src/mainview/components/options/DownloadDirectoryOption.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { changeDownloadsMutation } from "@/mainview/scripts/queries";
|
||||||
|
|
||||||
|
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||||
|
{
|
||||||
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const setSettingMutation = useMutation({
|
||||||
|
...changeDownloadsMutation,
|
||||||
|
onSuccess: (d, v, r, cx) =>
|
||||||
|
{
|
||||||
|
setDirty(r !== localValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <PathSettingsOptionBase
|
||||||
|
isDirty={dirty}
|
||||||
|
label={data.label}
|
||||||
|
id={data.id}
|
||||||
|
type={data.type}
|
||||||
|
save={setSettingMutation.mutate}
|
||||||
|
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||||
|
isDirectoryPicker={true}
|
||||||
|
localValue={localValue}
|
||||||
|
setLocalValue={(v) =>
|
||||||
|
{
|
||||||
|
setLocalValue(v);
|
||||||
|
setDirty(true);
|
||||||
|
}} />;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,6 @@ export function OptionInput (data: {
|
||||||
focusKey: data.name, onEnterPress: () =>
|
focusKey: data.name, onEnterPress: () =>
|
||||||
{
|
{
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
systemApi.api.system.show_keyboard.post();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -32,6 +31,21 @@ export function OptionInput (data: {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const handleFocus = () =>
|
||||||
|
{
|
||||||
|
option.focus();
|
||||||
|
if (inputRef.current)
|
||||||
|
{
|
||||||
|
var rect = inputRef.current?.getBoundingClientRect();
|
||||||
|
systemApi.api.system.show_keyboard.post({
|
||||||
|
XPosition: rect.x,
|
||||||
|
YPosition: rect.y,
|
||||||
|
Width: rect.width,
|
||||||
|
Height: rect.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||||
|
|
@ -47,7 +61,7 @@ export function OptionInput (data: {
|
||||||
defaultValue={data.defaultValue}
|
defaultValue={data.defaultValue}
|
||||||
type={data.type}
|
type={data.type}
|
||||||
autoComplete={data.autocomplete}
|
autoComplete={data.autocomplete}
|
||||||
onFocus={() => option.focus()}
|
onFocus={handleFocus}
|
||||||
placeholder={data.placeholder}
|
placeholder={data.placeholder}
|
||||||
onChange={data.onChange}
|
onChange={data.onChange}
|
||||||
onBlur={data.onBlur}
|
onBlur={data.onBlur}
|
||||||
|
|
|
||||||
156
src/mainview/components/options/PathSettingsOption.tsx
Normal file
156
src/mainview/components/options/PathSettingsOption.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||||
|
import { SettingsType } from "../../../shared/constants";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { OptionSpace } from "./OptionSpace";
|
||||||
|
import { OptionInput } from "./OptionInput";
|
||||||
|
import { settingsApi } from "../../scripts/clientApi";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
|
||||||
|
import { ContextDialog } from "../ContextDialog";
|
||||||
|
import FilePicker from "../FilePicker";
|
||||||
|
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
|
||||||
|
type KeysWithValueAssignableTo<T, Value> = {
|
||||||
|
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
export interface PathSettingsOptionParams
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||||
|
type: HTMLInputTypeAttribute;
|
||||||
|
placeholder?: string;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
children?: any;
|
||||||
|
onBrowseAction?: (path: string | undefined) => void;
|
||||||
|
requireConfirmation?: boolean;
|
||||||
|
isDirectoryPicker?: boolean;
|
||||||
|
allowNewFolderCreation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PathSettingsOption (data: PathSettingsOptionParams)
|
||||||
|
{
|
||||||
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const setSettingMutation = useMutation({
|
||||||
|
mutationKey: ["setting", data.id],
|
||||||
|
mutationFn: async (value: any) =>
|
||||||
|
{
|
||||||
|
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (d, v, r, cx) =>
|
||||||
|
{
|
||||||
|
setDirty(r !== localValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <PathSettingsOptionBase
|
||||||
|
isDirty={dirty}
|
||||||
|
label={data.label}
|
||||||
|
id={data.id}
|
||||||
|
type={data.type}
|
||||||
|
save={setSettingMutation.mutate}
|
||||||
|
localValue={localValue}
|
||||||
|
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||||
|
setLocalValue={(v) =>
|
||||||
|
{
|
||||||
|
setLocalValue(v);
|
||||||
|
setDirty(true);
|
||||||
|
}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
||||||
|
save: (value: string | undefined) => void;
|
||||||
|
localValue: string | undefined;
|
||||||
|
setLocalValue: (value: string | undefined) => void;
|
||||||
|
isDirty: boolean;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||||
|
const { data: defaultValue } = useQuery({
|
||||||
|
enabled: !!data.id,
|
||||||
|
queryKey: ["setting", data.id],
|
||||||
|
queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data.isDirty)
|
||||||
|
{
|
||||||
|
data.setLocalValue(String(value.value));
|
||||||
|
}
|
||||||
|
return value.value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const changed = defaultValue !== data.localValue;
|
||||||
|
|
||||||
|
const handleSelectPath = (path: string) =>
|
||||||
|
{
|
||||||
|
data.setLocalValue(path);
|
||||||
|
handleCloseSeatch();
|
||||||
|
if (data.requireConfirmation !== true)
|
||||||
|
{
|
||||||
|
data.save(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSeatch = () =>
|
||||||
|
{
|
||||||
|
setIsBrowsing(false);
|
||||||
|
setFocus(`${data.id}-browse`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () =>
|
||||||
|
{
|
||||||
|
if (data.requireConfirmation !== true)
|
||||||
|
{
|
||||||
|
data.save(data.localValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
|
||||||
|
<OptionInput
|
||||||
|
icon={data.icon}
|
||||||
|
name={`${data.id}-input`}
|
||||||
|
type={data.type}
|
||||||
|
placeholder={data.placeholder}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onChange={(e) =>
|
||||||
|
{
|
||||||
|
data.setLocalValue(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
value={data.localValue}
|
||||||
|
/>
|
||||||
|
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() =>
|
||||||
|
{
|
||||||
|
setIsBrowsing(true);
|
||||||
|
data.onBrowseAction?.(data.localValue);
|
||||||
|
}} type="button">
|
||||||
|
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
|
||||||
|
</Button>
|
||||||
|
{data.requireConfirmation === true && <Button
|
||||||
|
disabled={defaultValue === data.localValue}
|
||||||
|
id={`${data.id}-save`}
|
||||||
|
onAction={() => data.save(data.localValue)}
|
||||||
|
type="button">
|
||||||
|
<Save />
|
||||||
|
</Button>}
|
||||||
|
|
||||||
|
<ContextDialog className="h-[80vh] w-[60vw]" id={`file-picker-${data.id}`} open={isBrowsing} close={handleCloseSeatch} >
|
||||||
|
{isBrowsing && <FilePicker
|
||||||
|
isDirectoryPicker={data.isDirectoryPicker}
|
||||||
|
onSelect={handleSelectPath}
|
||||||
|
key={`download-path-${data.id}`}
|
||||||
|
startingPath={data.localValue}
|
||||||
|
id={`download-path-${data.id}`}
|
||||||
|
cancel={handleCloseSeatch}
|
||||||
|
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</ContextDialog>
|
||||||
|
{data.children}
|
||||||
|
</OptionSpace>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ export function SettingsOption (data: {
|
||||||
type: HTMLInputTypeAttribute;
|
type: HTMLInputTypeAttribute;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
|
children?: any;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
@ -67,6 +68,7 @@ export function SettingsOption (data: {
|
||||||
}}
|
}}
|
||||||
value={localValue}
|
value={localValue}
|
||||||
/>
|
/>
|
||||||
|
{data.children}
|
||||||
</OptionSpace>
|
</OptionSpace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -9,12 +9,11 @@ import
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { routeTree } from "./gen/routeTree.gen";
|
import { routeTree } from "./gen/routeTree.gen";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RPC_URL } from "../shared/constants";
|
import { RPC_URL } from "../shared/constants";
|
||||||
import "./scripts/gamepads";
|
import "./scripts/gamepads";
|
||||||
import "./scripts/windowEvents";
|
import "./scripts/windowEvents";
|
||||||
import { client as rommClient } from "../clients/romm/client.gen";
|
import { client as rommClient } from "../clients/romm/client.gen";
|
||||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
|
||||||
import "./scripts/spatialNavigation";
|
import "./scripts/spatialNavigation";
|
||||||
|
|
||||||
const hashHistory = createHashHistory({});
|
const hashHistory = createHashHistory({});
|
||||||
|
|
@ -50,6 +49,8 @@ export const Router = createRouter({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Register things for typesafety
|
// Register things for typesafety
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register
|
interface Register
|
||||||
|
|
@ -58,12 +59,6 @@ declare module "@tanstack/react-router" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRouterSsrQueryIntegration({
|
|
||||||
router: Router,
|
|
||||||
queryClient,
|
|
||||||
wrapQueryClient: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("root")!;
|
const rootElement = document.getElementById("root")!;
|
||||||
|
|
||||||
if (!rootElement.innerHTML)
|
if (!rootElement.innerHTML)
|
||||||
|
|
@ -71,7 +66,9 @@ if (!rootElement.innerHTML)
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={Router} />
|
<RouterProvider router={Router} />
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { 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 { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
export const Route = createFileRoute('/collection/$id')({
|
export const Route = createFileRoute('/collection/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -15,12 +16,13 @@ export const Route = createFileRoute('/collection/$id')({
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
|
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
|
||||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||||
"home-background",
|
"home-background",
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
|
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
{
|
{
|
||||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
|
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
|
||||||
if (error) throw error;
|
if (error)
|
||||||
|
{
|
||||||
|
if (error.value.message)
|
||||||
|
{
|
||||||
|
toast.error(error.value.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [progress, setProgress] = useState<number | undefined>(undefined);
|
const [progress, setProgress] = useState<number | undefined>(undefined);
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,16 @@ import
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
FocusContext,
|
FocusContext,
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} 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 { useEventListener } from "usehooks-ts";
|
import { useEventListener } from "usehooks-ts";
|
||||||
import
|
|
||||||
{
|
|
||||||
getCollectionsApiCollectionsGetOptions,
|
|
||||||
} from "../../clients/romm/@tanstack/react-query.gen";
|
|
||||||
import { CardList, GameMetaExtra } from "../components/CardList";
|
|
||||||
import { HeaderUI } from "../components/Header";
|
import { HeaderUI } from "../components/Header";
|
||||||
import { FilterUI } from "../components/Filters";
|
import { FilterUI } from "../components/Filters";
|
||||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||||
|
|
@ -47,10 +40,11 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
import CollectionList from "../components/CollectionList";
|
import CollectionList from "../components/CollectionList";
|
||||||
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: ConsoleHomeUI,
|
component: ConsoleHomeUI,
|
||||||
validateSearch: z.object({ filter: z.string().optional().default('games') })
|
validateSearch: zodValidator(z.object({ filter: z.string().optional().default('games') }))
|
||||||
});
|
});
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import { Router } from '..';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { rommApi } from '../scripts/clientApi';
|
import { rommApi } from '../scripts/clientApi';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||||
|
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
|
import Shortcuts from '../components/Shortcuts';
|
||||||
|
|
||||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -20,13 +23,11 @@ function RouteComponent ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
|
||||||
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
|
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
|
||||||
|
|
||||||
useEventListener("cancel", (e) =>
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||||
{
|
const { shortcuts } = useShortcutContext();
|
||||||
e.stopPropagation();
|
|
||||||
HandleGoBack();
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -41,18 +42,27 @@ function RouteComponent ()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.addEventListener('refresh', HandleGoBack);
|
es.addEventListener('refresh', () =>
|
||||||
|
{
|
||||||
|
HandleGoBack();
|
||||||
|
});
|
||||||
|
|
||||||
es.onerror = HandleGoBack;
|
es.onerror = () =>
|
||||||
|
{
|
||||||
|
HandleGoBack();
|
||||||
|
};
|
||||||
|
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return <AnimatedBackground backgroundKey='game-details'>
|
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
|
||||||
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
||||||
<DotsLoading />
|
<DotsLoading />
|
||||||
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
|
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='absolute bot'>
|
||||||
|
<Shortcuts shortcuts={shortcuts} />
|
||||||
|
</div>
|
||||||
</AnimatedBackground>;
|
</AnimatedBackground>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { rommApi } from "../scripts/clientApi";
|
import { rommApi } from "../scripts/clientApi";
|
||||||
|
|
@ -10,10 +10,21 @@ export const Route = createFileRoute("/platform/$source/$id")({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
});
|
});
|
||||||
|
|
||||||
function PlatformTitle ()
|
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
|
||||||
|
{
|
||||||
|
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||||
|
|
||||||
|
<div className="divider mb-6 mt-0">
|
||||||
|
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
|
||||||
|
{data.platformName}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
const { data: platform } = useSuspenseQuery({
|
const { data: platform } = useQuery({
|
||||||
queryKey: ['platform', source, id], queryFn: async () =>
|
queryKey: ['platform', source, id], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||||
|
|
@ -22,33 +33,18 @@ function PlatformTitle ()
|
||||||
}, staleTime: DefaultRommStaleTime
|
}, staleTime: DefaultRommStaleTime
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
|
||||||
|
|
||||||
<div className="divider mb-6 mt-0">
|
|
||||||
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
|
||||||
{platform.display_name}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent ()
|
|
||||||
{
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
|
|
||||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||||
"home-background",
|
"home-background",
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
const navigate = useNavigate();
|
|
||||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<CollectionsDetail
|
{!!platform && <CollectionsDetail
|
||||||
title={<Suspense><PlatformTitle /></Suspense>}
|
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
|
||||||
setBackground={setBackground}
|
setBackground={setBackground}
|
||||||
filters={{ platformId: Number(id) }}
|
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,6 @@ 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,11 +1,68 @@
|
||||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { Block, createFileRoute, useBlocker } from '@tanstack/react-router';
|
||||||
import { SettingsOption } from '../../components/options/SettingsOption';
|
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
||||||
|
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries';
|
||||||
|
import { DownloadsDrive } from '@/shared/constants';
|
||||||
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
|
import { Download, FolderOpen, HardDrive, Usb } from 'lucide-react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||||
|
import data from '@emulators';
|
||||||
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
|
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/directories')({
|
export const Route = createFileRoute('/settings/directories')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; })
|
||||||
|
{
|
||||||
|
const { ref, focused, focusKey } = useFocusable({ focusKey: data.drive.device });
|
||||||
|
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||||
|
const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
|
||||||
|
const usedPercent = usedWithoutDownlods / data.drive.size;
|
||||||
|
const usedPercentRaw = data.drive.used / data.drive.size;
|
||||||
|
const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
|
||||||
|
const shortcuts: Shortcut[] = [];
|
||||||
|
if (!data.drive.unusableReason && isMoving <= 0)
|
||||||
|
{
|
||||||
|
shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: () => changeDownloads.mutate(data.drive.mountPoint) });
|
||||||
|
}
|
||||||
|
useShortcuts(focusKey, () => shortcuts, [shortcuts]);
|
||||||
|
|
||||||
|
|
||||||
|
return <li ref={ref} className={twMerge('flex flex-col p-4 bg-base-300 rounded-2xl gap-1',
|
||||||
|
classNames({
|
||||||
|
"ring-7": focused,
|
||||||
|
"border-dashed border-primary border-7": data.drive.isCurrentlyUsed,
|
||||||
|
"border-solid": data.drive.unusableReason === 'already_used',
|
||||||
|
"ring-error": data.drive.unusableReason === 'not_enough_space',
|
||||||
|
}))}>
|
||||||
|
<div className='flex gap-2 font-semibold'>{data.drive.isRemovable ? <Usb /> : <HardDrive />}{data.drive.label}</div>
|
||||||
|
<small className='opacity-60'>{data.drive.mountPoint}</small>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
{prettyBytes(data.drive.size - data.drive.used)} Free
|
||||||
|
{data.drive.unusableReason === 'not_enough_space' && <p className='text-error'>(Not Enough Space)</p>}
|
||||||
|
{data.drive.unusableReason === 'already_used' && <p>(Currently Used)</p>}
|
||||||
|
{data.drive.unusableReason !== 'already_used' && data.drive.isCurrentlyUsed && <p className='opacity-60'>(Custom Path)</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={twMerge("progress", classNames({
|
||||||
|
"progress-warning": usedPercent > 0.8,
|
||||||
|
"progress-error": data.drive.unusableReason === 'not_enough_space',
|
||||||
|
}))}>
|
||||||
|
<div className={twMerge('h-full bg-primary', classNames({
|
||||||
|
"bg-warning": usedPercent > 0.8,
|
||||||
|
"bg-error": data.drive.unusableReason === 'not_enough_space',
|
||||||
|
}))} style={{ width: usedPercent.toLocaleString('en-US', { style: 'percent' }) }}></div>
|
||||||
|
{!!data.drive.isCurrentlyUsed && <div className="h-full bg-base-content" style={{ width: usedPercentRaw.toLocaleString('en-US', { style: 'percent' }) }}></div>}
|
||||||
|
</div>
|
||||||
|
</li>;
|
||||||
|
}
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { focus } = Route.useSearch();
|
const { focus } = Route.useSearch();
|
||||||
|
|
@ -13,14 +70,34 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||||
|
const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
|
||||||
|
|
||||||
return <FocusContext value={focusKey}>
|
return <FocusContext value={focusKey}>
|
||||||
|
<Block shouldBlockFn={() => isMoving} withResolver={false} />
|
||||||
<ul ref={ref} className="list rounded-box gap-2">
|
<ul ref={ref} className="list rounded-box gap-2">
|
||||||
<div className="divider text-2xl mt-0 md:mt-4">
|
<div className="divider text-2xl mt-0 md:mt-4">
|
||||||
<div className="flex flex-col">
|
<Download className='size-16' /> Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : '?'})
|
||||||
<h3>Romm</h3>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ul className='p-2 grid grid-cols-2 gap-3'>
|
||||||
<SettingsOption label="Download Path" id="downloadPath" type="text" />
|
{drives?.drives.filter(d => d.mountPoint).map(d => <DriveComponent refetchDrives={refetch} downloadsSize={drives.downloadsSize} drive={d} />)}
|
||||||
</ul>
|
</ul>
|
||||||
</FocusContext>;
|
<DownloadDirectoryOption
|
||||||
|
isDirectoryPicker
|
||||||
|
requireConfirmation
|
||||||
|
allowNewFolderCreation
|
||||||
|
label="Custom Download Path"
|
||||||
|
id="downloadPath"
|
||||||
|
type="text" >
|
||||||
|
|
||||||
|
</DownloadDirectoryOption>
|
||||||
|
<OptionSpace label="Config Path" id='config'>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
{drives?.configPath}
|
||||||
|
<Button id='open-config' type='button' onAction={() => systemApi.api.system.open.post({ url: drives?.configPath ?? '' })} ><FolderOpen /></Button>
|
||||||
|
</div>
|
||||||
|
</OptionSpace>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</FocusContext >;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { settingsApi } from '../../scripts/clientApi';
|
import { settingsApi } from '../../scripts/clientApi';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Button } from '../../components/options/Button';
|
import { Button } from '../../components/options/Button';
|
||||||
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
@ -13,6 +13,8 @@ import { RPC_URL } from '../../../shared/constants';
|
||||||
import emulators from '@emulators';
|
import emulators from '@emulators';
|
||||||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
|
import FilePicker from '@/mainview/components/FilePicker';
|
||||||
|
import { dirname } from 'pathe';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/emulators')({
|
export const Route = createFileRoute('/settings/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -90,6 +92,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
|
||||||
|
|
||||||
function EmulatorPath (data: { id: string; })
|
function EmulatorPath (data: { id: string; })
|
||||||
{
|
{
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
const { data: remoteValue } = useQuery({
|
const { data: remoteValue } = useQuery({
|
||||||
|
|
@ -109,6 +112,8 @@ function EmulatorPath (data: { id: string; })
|
||||||
{
|
{
|
||||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||||
|
setLocalValue(v);
|
||||||
|
setDirty(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
|
|
@ -129,11 +134,23 @@ function EmulatorPath (data: { id: string; })
|
||||||
{
|
{
|
||||||
if (dirty)
|
if (dirty)
|
||||||
{
|
{
|
||||||
setDirty(false);
|
|
||||||
setSettingMutation.mutate(localValue ?? '');
|
setSettingMutation.mutate(localValue ?? '');
|
||||||
}
|
}
|
||||||
}, [dirty, setDirty, localValue]);
|
}, [dirty, setDirty, localValue]);
|
||||||
|
|
||||||
|
const handleCloseSearch = () =>
|
||||||
|
{
|
||||||
|
setIsSearching(false);
|
||||||
|
setFocus(`search-${data.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPath = (path: string) =>
|
||||||
|
{
|
||||||
|
setIsSearching(false);
|
||||||
|
setSettingMutation.mutate(path);
|
||||||
|
setFocus(`search-${data.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
|
|
@ -150,9 +167,33 @@ function EmulatorPath (data: { id: string; })
|
||||||
}}
|
}}
|
||||||
value={localValue}
|
value={localValue}
|
||||||
/>
|
/>
|
||||||
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
<Button shortcutLabel="Remove" id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
||||||
<Trash />
|
<Trash />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
id={`search-${data.id}`}
|
||||||
|
className='p-2'
|
||||||
|
onAction={() => setIsSearching(true)}
|
||||||
|
shortcutLabel={"Search"}
|
||||||
|
type='button' >
|
||||||
|
<FolderSearch />
|
||||||
|
</Button>
|
||||||
|
<ContextDialog
|
||||||
|
className='h-[80vh] w-[60vw]'
|
||||||
|
id={`file-picker-${data.id}`}
|
||||||
|
open={isSearching}
|
||||||
|
close={handleCloseSearch}
|
||||||
|
preferredChildFocusKey={`main-download-path-${data.id}`}
|
||||||
|
>
|
||||||
|
{isSearching && <FilePicker
|
||||||
|
onSelect={handleSelectPath}
|
||||||
|
key={`download-path-${data.id}`}
|
||||||
|
startingPath={remoteValue ? dirname(remoteValue) : undefined}
|
||||||
|
id={`download-path-${data.id}`}
|
||||||
|
cancel={handleCloseSearch}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</ContextDialog>
|
||||||
</div>
|
</div>
|
||||||
</OptionSpace>
|
</OptionSpace>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ function MenuItem (data: {
|
||||||
const acitve = matchRoute({ to: data.route });
|
const acitve = matchRoute({ to: data.route });
|
||||||
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
|
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
|
||||||
const { ref, focusSelf, focused } = useFocusable({
|
const { ref, focusSelf, focused } = useFocusable({
|
||||||
focusKey: data.route,
|
focusKey: `menu-item-${data.route}`,
|
||||||
forceFocus: !!acitve,
|
forceFocus: !!acitve,
|
||||||
onFocus: () =>
|
onFocus: () =>
|
||||||
{
|
{
|
||||||
|
|
@ -119,8 +119,8 @@ function SettingsMenu (data: {})
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
focusSelect
|
focusSelect
|
||||||
route="/settings/visual"
|
route="/settings/interface"
|
||||||
label="Visual"
|
label="Interface"
|
||||||
icon={<MonitorCog />}
|
icon={<MonitorCog />}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -156,18 +156,12 @@ function SettingsMenu (data: {})
|
||||||
function HandleGoBack ()
|
function HandleGoBack ()
|
||||||
{
|
{
|
||||||
|
|
||||||
if (document.activeElement && document.activeElement !== document.body && document.activeElement instanceof HTMLElement)
|
|
||||||
{
|
|
||||||
document.activeElement.blur();
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const source = PopSource('settings');
|
const source = PopSource('settings');
|
||||||
if (source)
|
if (source)
|
||||||
{
|
{
|
||||||
console.log("Found source ", source, " to go back to");
|
console.log("Found source ", source, " to go back to");
|
||||||
}
|
}
|
||||||
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@ export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
|
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
|
||||||
keepDomain: true,
|
keepDomain: true,
|
||||||
fetch: {
|
fetch: {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
|
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
|
||||||
keepDomain: true,
|
keepDomain: true,
|
||||||
fetch: {
|
fetch: {
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,16 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
|
||||||
|
|
||||||
let loopStarted = false;
|
let loopStarted = false;
|
||||||
|
|
||||||
|
const handleLoop = () =>
|
||||||
window.addEventListener("gamepadconnected", (evt) =>
|
|
||||||
{
|
{
|
||||||
if (!loopStarted)
|
if (!loopStarted)
|
||||||
{
|
{
|
||||||
requestAnimationFrame(updateStatus);
|
requestAnimationFrame(updateStatus);
|
||||||
loopStarted = true;
|
loopStarted = true;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
window.addEventListener("gamepadconnected", handleLoop);
|
||||||
window.addEventListener("gamepaddisconnected", (evt) =>
|
import.meta.hot.dispose(() => window.addEventListener('gamepaddisconnected', handleLoop));
|
||||||
{
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const throttleMap = new Map<string, number>();
|
const throttleMap = new Map<string, number>();
|
||||||
const throttleAcceleration = new Map<string, number>();
|
const throttleAcceleration = new Map<string, number>();
|
||||||
|
|
@ -36,7 +32,7 @@ function throttleNav (key: string, dir: string, event: Event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', e =>
|
/*window.addEventListener('keydown', e =>
|
||||||
{
|
{
|
||||||
if (e.key === 'Escape')
|
if (e.key === 'Escape')
|
||||||
{
|
{
|
||||||
|
|
@ -45,7 +41,7 @@ window.addEventListener('keydown', e =>
|
||||||
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
||||||
finalTarget.dispatchEvent(evn);
|
finalTarget.dispatchEvent(evn);
|
||||||
}
|
}
|
||||||
});
|
});*/
|
||||||
|
|
||||||
export class GamepadButtonEvent extends Event
|
export class GamepadButtonEvent extends Event
|
||||||
{
|
{
|
||||||
|
|
|
||||||
54
src/mainview/scripts/queries.ts
Normal file
54
src/mainview/scripts/queries.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
|
import { settingsApi, systemApi } from "./clientApi";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { getErrorMessage } from "react-error-boundary";
|
||||||
|
|
||||||
|
export const drivesQuery = queryOptions({
|
||||||
|
queryKey: ['drives'],
|
||||||
|
queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await systemApi.api.system.drives.get();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadDrivesQuery = queryOptions({
|
||||||
|
queryKey: ['drives', 'download'],
|
||||||
|
queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await systemApi.api.system.drives.download.get();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({
|
||||||
|
queryKey: ['files', currentPath ?? '', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changeDownloadsMutation = mutationOptions({
|
||||||
|
mutationKey: ["setting", "downloads"],
|
||||||
|
mutationFn: async (value: any) =>
|
||||||
|
{
|
||||||
|
const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d =>
|
||||||
|
{
|
||||||
|
if (d.error) throw d.error;
|
||||||
|
return d.data;
|
||||||
|
}), {
|
||||||
|
success: e => `Download Moved to ${e}`,
|
||||||
|
loading: "Moving Download",
|
||||||
|
error: e => getErrorMessage(e) ?? "Error Moving Download"
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -32,9 +32,25 @@ export interface Shortcut
|
||||||
{
|
{
|
||||||
label?: string;
|
label?: string;
|
||||||
button: GamePadButtonCode;
|
button: GamePadButtonCode;
|
||||||
action: (e: GamepadButtonEvent) => void;
|
action?: (e: GamepadButtonEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isDirty = false;
|
||||||
|
const shortcutChangeDispatcher = setInterval(() =>
|
||||||
|
{
|
||||||
|
window.dispatchEvent(new Event('shortcutsChanged'));
|
||||||
|
isDirty = false;
|
||||||
|
}, 100);
|
||||||
|
import.meta.hot.dispose(() => clearInterval(shortcutChangeDispatcher));
|
||||||
|
|
||||||
|
function markDirtyThrottled ()
|
||||||
|
{
|
||||||
|
isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focuschanged', markDirtyThrottled);
|
||||||
|
import.meta.hot.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
|
||||||
|
|
||||||
export function useShortcutContext ()
|
export function useShortcutContext ()
|
||||||
{
|
{
|
||||||
const [array, setArray] = useState<Shortcut[] | undefined>();
|
const [array, setArray] = useState<Shortcut[] | undefined>();
|
||||||
|
|
@ -44,7 +60,8 @@ export function useShortcutContext ()
|
||||||
const handleShortcutRebuild = () =>
|
const handleShortcutRebuild = () =>
|
||||||
{
|
{
|
||||||
conflictSet.clear();
|
conflictSet.clear();
|
||||||
const newArray = GetFocusedTree(getCurrentFocusKey())
|
const focusKey = getCurrentFocusKey();
|
||||||
|
const newArray = GetFocusedTree(focusKey)
|
||||||
.filter(f => shortcutMap.has(f))
|
.filter(f => shortcutMap.has(f))
|
||||||
.flatMap(f => shortcutMap.get(f)!)
|
.flatMap(f => shortcutMap.get(f)!)
|
||||||
.filter(s =>
|
.filter(s =>
|
||||||
|
|
@ -65,7 +82,7 @@ export function useShortcutContext ()
|
||||||
const event = e as GamepadButtonEvent;
|
const event = e as GamepadButtonEvent;
|
||||||
if (shortcuts.has(event.button))
|
if (shortcuts.has(event.button))
|
||||||
{
|
{
|
||||||
shortcuts.get(event.button)?.action(event);
|
shortcuts.get(event.button)?.action?.(event);
|
||||||
}
|
}
|
||||||
else if (event.button === GamePadButtonCode.A)
|
else if (event.button === GamePadButtonCode.A)
|
||||||
{
|
{
|
||||||
|
|
@ -74,6 +91,20 @@ export function useShortcutContext ()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) =>
|
||||||
|
{
|
||||||
|
if (e.key === 'Escape')
|
||||||
|
{
|
||||||
|
shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B }));
|
||||||
|
} else if (e.key === 'Backspace')
|
||||||
|
{
|
||||||
|
shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X }));
|
||||||
|
} else if (e.key === ' ')
|
||||||
|
{
|
||||||
|
shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleGamepadButtonUp = (e: Event) =>
|
const handleGamepadButtonUp = (e: Event) =>
|
||||||
{
|
{
|
||||||
const event = e as GamepadButtonEvent;
|
const event = e as GamepadButtonEvent;
|
||||||
|
|
@ -108,14 +139,16 @@ export function useShortcutContext ()
|
||||||
handleShortcutRebuild();
|
handleShortcutRebuild();
|
||||||
}
|
}
|
||||||
window.addEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
window.addEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
window.addEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
window.addEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
||||||
window.addEventListener('focuschanged', handleShortcutRebuild);
|
window.addEventListener('shortcutsChanged', handleShortcutRebuild);
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
{
|
{
|
||||||
window.removeEventListener('focuschanged', handleShortcutRebuild);
|
|
||||||
window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
||||||
window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
||||||
|
window.removeEventListener('shortcutsChanged', handleShortcutRebuild);
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
};
|
};
|
||||||
}, [array]);
|
}, [array]);
|
||||||
|
|
||||||
|
|
@ -127,6 +160,7 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
shortcutMap.set(focusKey, build());
|
shortcutMap.set(focusKey, build());
|
||||||
|
markDirtyThrottled();
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
|
FocusDetails,
|
||||||
getCurrentFocusKey,
|
getCurrentFocusKey,
|
||||||
init,
|
init,
|
||||||
SpatialNavigation,
|
SpatialNavigation,
|
||||||
|
|
@ -13,7 +14,7 @@ init({
|
||||||
|
|
||||||
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
|
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
|
||||||
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
||||||
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
|
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
|
||||||
|
|
||||||
type SaveFocusType = "session" | "local";
|
type SaveFocusType = "session" | "local";
|
||||||
|
|
||||||
|
|
@ -27,7 +28,6 @@ export function SaveSource (id: HistorySourceType, url?: string)
|
||||||
{
|
{
|
||||||
historySourceMap.set(id, finalUrl);
|
historySourceMap.set(id, finalUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HasSource (id: HistorySourceType)
|
export function HasSource (id: HistorySourceType)
|
||||||
|
|
@ -95,10 +95,10 @@ export function useFocusEventListener<K extends keyof FocusEventMap, O extends H
|
||||||
}, [eventName, handler, element?.current]);
|
}, [eventName, handler, element?.current]);
|
||||||
}
|
}
|
||||||
|
|
||||||
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
|
SpatialNavigation.setFocus = (newFocusKey, focusDetails) =>
|
||||||
{
|
{
|
||||||
setCurrentFocusedKey(newFocusKey, focusDetails);
|
setFocus(newFocusKey, focusDetails);
|
||||||
dispatchFocusedEvent(new Event('focuschanged', { bubbles: true }));
|
dispatchFocusedEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
|
||||||
};
|
};
|
||||||
|
|
||||||
SpatialNavigation.addFocusable = (toAdd) =>
|
SpatialNavigation.addFocusable = (toAdd) =>
|
||||||
|
|
@ -174,8 +174,6 @@ SpatialNavigation.removeFocusable = ({ focusKey }) =>
|
||||||
|
|
||||||
removeFocusable(component);
|
removeFocusable(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SpatialNavigation.saveLastFocusedChildKey = (component, focusKey) =>
|
SpatialNavigation.saveLastFocusedChildKey = (component, focusKey) =>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { settingsApi } from "./clientApi";
|
import { settingsApi } from "./clientApi";
|
||||||
|
|
||||||
window.addEventListener("resize", () =>
|
const handleResize = () =>
|
||||||
{
|
{
|
||||||
settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
|
settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
|
||||||
});
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
import.meta.hot.dispose(() => window.removeEventListener('resize', handleResize));
|
||||||
|
|
||||||
let lastWindowPosX: number = window.screenX;
|
let lastWindowPosX: number = window.screenX;
|
||||||
let lastWindowPosY: number = window.screenY;
|
let lastWindowPosY: number = window.screenY;
|
||||||
|
|
@ -17,3 +19,4 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
|
||||||
lastWindowPosX = window.screenX;
|
lastWindowPosX = window.screenX;
|
||||||
lastWindowPosY = window.screenY;
|
lastWindowPosY = window.screenY;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
import.meta.hot.dispose(() => clearInterval(screenPositionInternal));
|
||||||
|
|
@ -29,6 +29,18 @@ export const SettingsSchema = z.object({
|
||||||
downloadPath: z.string().default('./downloads')
|
downloadPath: z.string().default('./downloads')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const GameListFilterSchema = z.object({
|
||||||
|
platform_source: z.string().optional(),
|
||||||
|
platform_slug: z.string().optional(),
|
||||||
|
platform_id: z.coerce.number().optional(),
|
||||||
|
collection_id: z.coerce.number().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GameListFilterType = z.infer<typeof GameListFilterSchema>;
|
||||||
|
|
||||||
|
export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() });
|
||||||
|
export type DirType = z.infer<typeof DirSchema>;
|
||||||
|
|
||||||
export const CustomEmulatorSchema = z.record(z.string(), z.string());
|
export const CustomEmulatorSchema = z.record(z.string(), z.string());
|
||||||
|
|
||||||
export interface FrontEndId
|
export interface FrontEndId
|
||||||
|
|
@ -40,14 +52,13 @@ export interface FrontEndId
|
||||||
export interface FrontEndPlatformType
|
export interface FrontEndPlatformType
|
||||||
{
|
{
|
||||||
id: FrontEndId;
|
id: FrontEndId;
|
||||||
source: string | null;
|
|
||||||
source_id: number | null;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
family_name?: string | null;
|
family_name?: string | null;
|
||||||
path_cover: string | null;
|
path_cover: string | null;
|
||||||
game_count: number;
|
game_count: number;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
|
hasLocal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrontEndGameType
|
export interface FrontEndGameType
|
||||||
|
|
@ -79,6 +90,33 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Drive
|
||||||
|
{
|
||||||
|
parent: string | null;
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
mountPoint: string | null;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
used: number;
|
||||||
|
isRemovable: boolean;
|
||||||
|
interfaceType: string | null;
|
||||||
|
hasWriteAccess: boolean;
|
||||||
|
hasReadAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsDrive
|
||||||
|
{
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
mountPoint: string | null;
|
||||||
|
isRemovable: boolean;
|
||||||
|
size: number;
|
||||||
|
used: number;
|
||||||
|
isCurrentlyUsed: boolean;
|
||||||
|
unusableReason: 'not_enough_space' | 'already_used' | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Notification
|
export interface Notification
|
||||||
{
|
{
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons-ng';
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons-ng';
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import staticAssetsPlugin from 'vite-static-assets-plugin';
|
import staticAssetsPlugin from 'vite-static-assets-plugin';
|
||||||
import { host } from "./src/bun/utils";
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
|
|
||||||
export default defineConfig(() =>
|
export default defineConfig(() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue