From edbc390d144bf44da35d0f5383ec36eb25c34d1b Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Wed, 1 Apr 2026 21:20:34 +0300 Subject: [PATCH 01/38] feat: Implemented audio effects --- .gitattributes | 3 + README.md | 6 + bun.lock | 56 +++- package.json | 7 +- scripts/generate-audio-sprites.ts | 24 ++ src/mainview/App.tsx | 48 +++ src/mainview/assets/sounds.json | 304 ++++++++++++++++++ src/mainview/assets/sounds.ogg | 3 + src/mainview/components/AutoFocus.tsx | 4 +- src/mainview/components/CardElement.tsx | 11 +- src/mainview/components/CollectionList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 13 +- src/mainview/components/ContextDialog.tsx | 11 +- src/mainview/components/Error.tsx | 8 +- src/mainview/components/Filters.tsx | 21 +- src/mainview/components/FrontEndGameCard.tsx | 5 +- src/mainview/components/Header.tsx | 34 +- src/mainview/components/LoadMoreButton.tsx | 1 + src/mainview/components/NotFound.tsx | 7 +- src/mainview/components/Screenshots.tsx | 2 +- .../components/game/ActionButtons.tsx | 6 +- src/mainview/components/game/MainActions.tsx | 15 +- src/mainview/components/options/Button.tsx | 10 +- .../components/options/OptionDropdown.tsx | 8 +- .../components/options/OptionInput.tsx | 4 + .../components/options/PathSettingsOption.tsx | 2 +- .../components/store/EmulatorsSection.tsx | 7 +- .../components/store/GamesSection.tsx | 2 +- .../store/MissingEmulatorsSection.tsx | 7 +- .../components/store/StoreEmulatorCard.tsx | 10 +- src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.tsx | 37 +-- src/mainview/routes/__root.tsx | 5 +- src/mainview/routes/embedded.$source.$id.tsx | 10 +- src/mainview/routes/game/$source.$id.tsx | 35 +- src/mainview/routes/index.tsx | 35 +- src/mainview/routes/launcher.$source.$id.tsx | 6 +- src/mainview/routes/settings/accounts.tsx | 2 +- src/mainview/routes/settings/emulators.tsx | 14 +- src/mainview/routes/settings/interface.tsx | 1 + src/mainview/routes/settings/route.tsx | 30 +- .../routes/store/details.emulator.$id.tsx | 27 +- src/mainview/routes/store/tab/route.tsx | 36 +-- src/mainview/scripts/audio/audio.ts | 70 ++++ src/mainview/scripts/audio/audioCallbacks.ts | 90 ++++++ src/mainview/scripts/gamepads.ts | 4 + src/mainview/scripts/spatialNavigation.ts | 15 +- src/mainview/scripts/utils.ts | 44 ++- src/mainview/types.d.ts | 19 ++ src/shared/constants.ts | 3 +- src/sounds/Classic UI SFX - Chords #1.wav | 3 + src/sounds/Classic UI SFX - Chords #10.wav | 3 + src/sounds/Classic UI SFX - Chords #11.wav | 3 + src/sounds/Classic UI SFX - Chords #12.wav | 3 + src/sounds/Classic UI SFX - Chords #13.wav | 3 + src/sounds/Classic UI SFX - Chords #14.wav | 3 + src/sounds/Classic UI SFX - Chords #15.wav | 3 + src/sounds/Classic UI SFX - Chords #16.wav | 3 + src/sounds/Classic UI SFX - Chords #17.wav | 3 + src/sounds/Classic UI SFX - Chords #18.wav | 3 + src/sounds/Classic UI SFX - Chords #19.wav | 3 + src/sounds/Classic UI SFX - Chords #2.wav | 3 + src/sounds/Classic UI SFX - Chords #20.wav | 3 + src/sounds/Classic UI SFX - Chords #3.wav | 3 + src/sounds/Classic UI SFX - Chords #4.wav | 3 + src/sounds/Classic UI SFX - Chords #5.wav | 3 + src/sounds/Classic UI SFX - Chords #6.wav | 3 + src/sounds/Classic UI SFX - Chords #7.wav | 3 + src/sounds/Classic UI SFX - Chords #8.wav | 3 + src/sounds/Classic UI SFX - Chords #9.wav | 3 + .../Classic UI SFX - Short - High #1.wav | 3 + .../Classic UI SFX - Short - High #10.wav | 3 + .../Classic UI SFX - Short - High #11.wav | 3 + .../Classic UI SFX - Short - High #12.wav | 3 + .../Classic UI SFX - Short - High #13.wav | 3 + .../Classic UI SFX - Short - High #14.wav | 3 + .../Classic UI SFX - Short - High #15.wav | 3 + .../Classic UI SFX - Short - High #16.wav | 3 + .../Classic UI SFX - Short - High #17.wav | 3 + .../Classic UI SFX - Short - High #18.wav | 3 + .../Classic UI SFX - Short - High #19.wav | 3 + .../Classic UI SFX - Short - High #2.wav | 3 + .../Classic UI SFX - Short - High #20.wav | 3 + .../Classic UI SFX - Short - High #21.wav | 3 + .../Classic UI SFX - Short - High #22.wav | 3 + .../Classic UI SFX - Short - High #23.wav | 3 + .../Classic UI SFX - Short - High #24.wav | 3 + .../Classic UI SFX - Short - High #25.wav | 3 + .../Classic UI SFX - Short - High #3.wav | 3 + .../Classic UI SFX - Short - High #4.wav | 3 + .../Classic UI SFX - Short - High #5.wav | 3 + .../Classic UI SFX - Short - High #6.wav | 3 + .../Classic UI SFX - Short - High #7.wav | 3 + .../Classic UI SFX - Short - High #8.wav | 3 + .../Classic UI SFX - Short - High #9.wav | 3 + .../Classic UI SFX - Short - Low #1.wav | 3 + .../Classic UI SFX - Short - Low #10.wav | 3 + .../Classic UI SFX - Short - Low #11.wav | 3 + .../Classic UI SFX - Short - Low #12.wav | 3 + .../Classic UI SFX - Short - Low #13.wav | 3 + .../Classic UI SFX - Short - Low #14.wav | 3 + .../Classic UI SFX - Short - Low #15.wav | 3 + .../Classic UI SFX - Short - Low #16.wav | 3 + .../Classic UI SFX - Short - Low #17.wav | 3 + .../Classic UI SFX - Short - Low #18.wav | 3 + .../Classic UI SFX - Short - Low #19.wav | 3 + .../Classic UI SFX - Short - Low #2.wav | 3 + .../Classic UI SFX - Short - Low #20.wav | 3 + .../Classic UI SFX - Short - Low #21.wav | 3 + .../Classic UI SFX - Short - Low #22.wav | 3 + .../Classic UI SFX - Short - Low #23.wav | 3 + .../Classic UI SFX - Short - Low #24.wav | 3 + .../Classic UI SFX - Short - Low #25.wav | 3 + .../Classic UI SFX - Short - Low #3.wav | 3 + .../Classic UI SFX - Short - Low #4.wav | 3 + .../Classic UI SFX - Short - Low #5.wav | 3 + .../Classic UI SFX - Short - Low #6.wav | 3 + .../Classic UI SFX - Short - Low #7.wav | 3 + .../Classic UI SFX - Short - Low #8.wav | 3 + .../Classic UI SFX - Short - Low #9.wav | 3 + src/sounds/UI_Single_Set 16_01.ogg | 3 + src/sounds/UI_Single_Set 16_02.ogg | 3 + src/sounds/UI_Single_Set 16_03.ogg | 3 + src/sounds/UI_TwoNote_Set 15_01.ogg | 3 + src/sounds/UI_TwoNote_Set 15_02.ogg | 3 + 125 files changed, 1137 insertions(+), 217 deletions(-) create mode 100644 scripts/generate-audio-sprites.ts create mode 100644 src/mainview/App.tsx create mode 100644 src/mainview/assets/sounds.json create mode 100644 src/mainview/assets/sounds.ogg create mode 100644 src/mainview/scripts/audio/audio.ts create mode 100644 src/mainview/scripts/audio/audioCallbacks.ts create mode 100644 src/sounds/Classic UI SFX - Chords #1.wav create mode 100644 src/sounds/Classic UI SFX - Chords #10.wav create mode 100644 src/sounds/Classic UI SFX - Chords #11.wav create mode 100644 src/sounds/Classic UI SFX - Chords #12.wav create mode 100644 src/sounds/Classic UI SFX - Chords #13.wav create mode 100644 src/sounds/Classic UI SFX - Chords #14.wav create mode 100644 src/sounds/Classic UI SFX - Chords #15.wav create mode 100644 src/sounds/Classic UI SFX - Chords #16.wav create mode 100644 src/sounds/Classic UI SFX - Chords #17.wav create mode 100644 src/sounds/Classic UI SFX - Chords #18.wav create mode 100644 src/sounds/Classic UI SFX - Chords #19.wav create mode 100644 src/sounds/Classic UI SFX - Chords #2.wav create mode 100644 src/sounds/Classic UI SFX - Chords #20.wav create mode 100644 src/sounds/Classic UI SFX - Chords #3.wav create mode 100644 src/sounds/Classic UI SFX - Chords #4.wav create mode 100644 src/sounds/Classic UI SFX - Chords #5.wav create mode 100644 src/sounds/Classic UI SFX - Chords #6.wav create mode 100644 src/sounds/Classic UI SFX - Chords #7.wav create mode 100644 src/sounds/Classic UI SFX - Chords #8.wav create mode 100644 src/sounds/Classic UI SFX - Chords #9.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #1.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #10.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #11.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #12.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #13.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #14.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #15.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #16.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #17.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #18.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #19.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #2.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #20.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #21.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #22.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #23.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #24.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #25.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #3.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #4.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #5.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #6.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #7.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #8.wav create mode 100644 src/sounds/Classic UI SFX - Short - High #9.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #1.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #10.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #11.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #12.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #13.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #14.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #15.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #16.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #17.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #18.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #19.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #2.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #20.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #21.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #22.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #23.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #24.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #25.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #3.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #4.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #5.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #6.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #7.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #8.wav create mode 100644 src/sounds/Classic UI SFX - Short - Low #9.wav create mode 100644 src/sounds/UI_Single_Set 16_01.ogg create mode 100644 src/sounds/UI_Single_Set 16_02.ogg create mode 100644 src/sounds/UI_Single_Set 16_03.ogg create mode 100644 src/sounds/UI_TwoNote_Set 15_01.ogg create mode 100644 src/sounds/UI_TwoNote_Set 15_02.ogg diff --git a/.gitattributes b/.gitattributes index 0a0c0bd..01c3dfe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ *.gif filter=lfs diff=lfs merge=lfs -text *.webp filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/README.md b/README.md index b3578f1..040a745 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,9 @@ Focused on building a simple user experience and intuitive UI as a curated commu - [elysia](https://elysiajs.com/) for the APIs - [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible. - [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators + +### Credits + +- UI Sounds + - [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1) + - [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx) diff --git a/bun.lock b/bun.lock index edc9367..b111ebe 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "@tanstack/router-plugin": "^1.157.16", "@tanstack/zod-adapter": "^1.162.4", "@types/adm-zip": "^0.5.8", + "@types/audiosprite": "^0.7.3", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", @@ -63,6 +64,7 @@ "adm-zip": "^0.5.16", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", + "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", "concurrently": "^9.2.1", @@ -590,6 +592,8 @@ "@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="], + "@types/audiosprite": ["@types/audiosprite@0.7.3", "", {}, "sha512-P4rUuHPt2kWPMqyObfh1SfqS2H/ZuTxByh00ecuI2tOdvP5b8NznuBeQgemDXV9v8b4pewFPB9G3BuYRONqD7A=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -674,12 +678,14 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async": ["async@0.9.2", "", {}, "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomically": ["atomically@2.1.0", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q=="], + "audiosprite": ["audiosprite@0.7.2", "", { "dependencies": { "async": "~0.9.0", "glob": "^6.0.4", "mkdirp": "^0.5.0", "optimist": "~0.6.1", "underscore": "~1.8.3", "winston": "~1.0.0" }, "bin": { "audiosprite": "./cli.js" } }, "sha512-9Z6UwUuv4To5nUQNRIw5/Q3qA7HYm0ANzoW5EDGPEsU2oIRVgmIlLlm9YZfpPKoeUxt54vMStl2/762189VmJw=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], @@ -762,6 +768,8 @@ "colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="], + "colors": ["colors@1.0.3", "", {}, "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], @@ -840,6 +848,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cycle": ["cycle@1.0.3", "", {}, "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA=="], + "daisyui": ["daisyui@5.5.14", "", {}, "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg=="], "dargs": ["dargs@7.0.0", "", {}, "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg=="], @@ -954,6 +964,8 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1016,7 +1028,7 @@ "gitconfiglocal": ["gitconfiglocal@1.0.0", "", { "dependencies": { "ini": "^1.3.2" } }, "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ=="], - "glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="], + "glob": ["glob@6.0.4", "", { "dependencies": { "inflight": "^1.0.4", "inherits": "2", "minimatch": "2 || 3", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1066,6 +1078,8 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -1104,6 +1118,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="], + "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], @@ -1278,12 +1294,16 @@ "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "optimist": ["optimist@0.6.1", "", { "dependencies": { "minimist": "~0.0.1", "wordwrap": "~0.0.2" } }, "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1308,6 +1328,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1332,6 +1354,8 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "pkginfo": ["pkginfo@0.3.1", "", {}, "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="], @@ -1516,6 +1540,8 @@ "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + "standard-version": ["standard-version@9.5.0", "", { "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "3.1.25", "conventional-changelog-config-spec": "2.1.0", "conventional-changelog-conventionalcommits": "4.6.3", "conventional-recommended-bump": "6.1.0", "detect-indent": "^6.0.0", "detect-newline": "^3.1.0", "dotgitignore": "^2.1.0", "figures": "^3.1.0", "find-up": "^5.0.0", "git-semver-tags": "^4.0.0", "semver": "^7.1.1", "stringify-package": "^1.0.1", "yargs": "^16.0.0" }, "bin": { "standard-version": "bin/cli.js" } }, "sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1618,6 +1644,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "underscore": ["underscore@1.8.3", "", {}, "sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg=="], + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], @@ -1672,12 +1700,16 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "winston": ["winston@1.0.2", "", { "dependencies": { "async": "~1.0.0", "colors": "1.0.x", "cycle": "1.0.x", "eyes": "0.1.x", "isstream": "0.1.x", "pkginfo": "0.3.x", "stack-trace": "0.0.x" } }, "sha512-BLxJH3KCgJ2paj2xKYTQLpxdKr9URPDDDLJnRVcbud7izT+m8Xzt5Rod6mnNgEcfT0fRvhEy2Cj3cEnnQpa6qA=="], + + "wordwrap": ["wordwrap@0.0.3", "", {}, "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -1752,6 +1784,8 @@ "@jimp/wasm-webp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@node-minify/core/glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="], + "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], "@node-minify/terser/terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], @@ -1808,10 +1842,12 @@ "gitconfiglocal/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "handlebars/wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "html-encoding-sniffer/whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="], @@ -1832,6 +1868,8 @@ "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], + "optimist/minimist": ["minimist@0.0.10", "", {}, "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1840,6 +1878,8 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "portfinder/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "read-pkg/normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], "read-pkg-up/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], @@ -1870,6 +1910,8 @@ "vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "winston/async": ["async@1.0.0", "", {}, "sha512-5mO7DX4CbJzp9zjaFXusQQ4tzKJARjNB1Ih1pVBi8wkbmXy/xzIDgEMXxWePLzt2OdFwaxfneIlT1nCiXubrPQ=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1924,6 +1966,8 @@ "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "@node-minify/core/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "@node-minify/terser/terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -1938,8 +1982,6 @@ "get-pkg-repo/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "meow/read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -2068,6 +2110,8 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "dotgitignore/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "dotgitignore/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], diff --git a/package.json b/package.json index 79ddcec..7b6b2e6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "version:generate": "standard-version --sign", "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", - "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium" + "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", + "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts" }, "dependencies": { "7zip-bin": "^5.2.0", @@ -88,6 +89,7 @@ "@tanstack/router-plugin": "^1.157.16", "@tanstack/zod-adapter": "^1.162.4", "@types/adm-zip": "^0.5.8", + "@types/audiosprite": "^0.7.3", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", @@ -101,6 +103,7 @@ "adm-zip": "^0.5.16", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", + "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", "concurrently": "^9.2.1", @@ -129,4 +132,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/scripts/generate-audio-sprites.ts b/scripts/generate-audio-sprites.ts new file mode 100644 index 0000000..bcce3e8 --- /dev/null +++ b/scripts/generate-audio-sprites.ts @@ -0,0 +1,24 @@ +import audioSprite from 'audiosprite'; +import { $, which } from 'bun'; +import fs from "node:fs/promises"; +import path from 'node:path'; + +var files = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +console.log("Loaded", files.join(",")); + +await new Promise((resolve) => +{ + audioSprite( + files.map(f => path.join(path.resolve('./src/sounds'), f)), + { + output: path.resolve('./src/mainview/assets/sounds'), + path: path.resolve('./src/sounds'), + format: 'howler', + export: 'ogg' + }, async function (err, obj: any) + { + if (err) return console.error(err); + delete obj.urls; + Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); + }); +}); \ No newline at end of file diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx new file mode 100644 index 0000000..fb0d2db --- /dev/null +++ b/src/mainview/App.tsx @@ -0,0 +1,48 @@ +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { Router } from "."; +import { useEffect } from "react"; +import audioCallbacks from "./scripts/audio/audioCallbacks"; +import { client as rommClient } from "../clients/romm/client.gen"; +import { RPC_URL } from "@/shared/constants"; + +export const focusQueue: string[] = []; + +export default function App (data: { children: any; }) +{ + + useEffect(() => + { + const focusMap = new Map(); + rommClient.setConfig({ + baseUrl: `${RPC_URL(__HOST__)}/api/romm`, + credentials: "include", + mode: "cors", + }); + + const unsub = Router.history.subscribe((op) => + { + if (op.action.type === 'PUSH') + { + focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey()); + } else if (op.action.type === 'BACK') + { + if (focusMap.has(op.location.state.__TSR_index)) + { + focusQueue.pop(); + focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); + focusMap.delete(op.location.state.__TSR_index); + } + } + }); + + const audio = audioCallbacks(); + + return () => + { + unsub(); + audio.cleanup(); + }; + }, []); + + return <>{data.children}; +} \ No newline at end of file diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json new file mode 100644 index 0000000..ae3bae3 --- /dev/null +++ b/src/mainview/assets/sounds.json @@ -0,0 +1,304 @@ +{ + "sprite": { + "Classic UI SFX - Chords #1": [ + 0, + 4005.215419501134 + ], + "Classic UI SFX - Chords #10": [ + 6000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #11": [ + 12000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #12": [ + 18000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #13": [ + 24000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #14": [ + 30000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #15": [ + 36000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #16": [ + 42000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #17": [ + 48000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #18": [ + 54000, + 4005.215419501134 + ], + "Classic UI SFX - Chords #19": [ + 60000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #2": [ + 66000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #20": [ + 72000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #3": [ + 78000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #4": [ + 84000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #5": [ + 90000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #6": [ + 96000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #7": [ + 102000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #8": [ + 108000, + 4005.215419501127 + ], + "Classic UI SFX - Chords #9": [ + 114000, + 4005.215419501127 + ], + "Classic UI SFX - Short - High #1": [ + 120000, + 2546.893424036284 + ], + "Classic UI SFX - Short - High #10": [ + 124000, + 2552.0861678004535 + ], + "Classic UI SFX - Short - High #11": [ + 128000, + 2927.0975056689394 + ], + "Classic UI SFX - Short - High #12": [ + 132000, + 2927.0975056689394 + ], + "Classic UI SFX - Short - High #13": [ + 136000, + 3000 + ], + "Classic UI SFX - Short - High #14": [ + 140000, + 2802.0861678004394 + ], + "Classic UI SFX - Short - High #15": [ + 144000, + 2723.9455782312803 + ], + "Classic UI SFX - Short - High #16": [ + 148000, + 2927.0975056689394 + ], + "Classic UI SFX - Short - High #17": [ + 152000, + 2880.226757369627 + ], + "Classic UI SFX - Short - High #18": [ + 156000, + 2359.387755102034 + ], + "Classic UI SFX - Short - High #19": [ + 160000, + 3052.0861678004394 + ], + "Classic UI SFX - Short - High #2": [ + 165000, + 2843.7641723355964 + ], + "Classic UI SFX - Short - High #20": [ + 169000, + 2015.6462585034092 + ], + "Classic UI SFX - Short - High #21": [ + 173000, + 2005.215419501127 + ], + "Classic UI SFX - Short - High #22": [ + 177000, + 2489.5918367346894 + ], + "Classic UI SFX - Short - High #23": [ + 181000, + 2458.3446712018144 + ], + "Classic UI SFX - Short - High #24": [ + 185000, + 2093.7641723355964 + ], + "Classic UI SFX - Short - High #25": [ + 189000, + 2005.215419501127 + ], + "Classic UI SFX - Short - High #3": [ + 193000, + 2864.6031746031613 + ], + "Classic UI SFX - Short - High #4": [ + 197000, + 3031.2698412698464 + ], + "Classic UI SFX - Short - High #5": [ + 202000, + 2598.9795918367236 + ], + "Classic UI SFX - Short - High #6": [ + 206000, + 2427.0975056689394 + ], + "Classic UI SFX - Short - High #7": [ + 210000, + 2468.752834467125 + ], + "Classic UI SFX - Short - High #8": [ + 214000, + 2916.666666666657 + ], + "Classic UI SFX - Short - High #9": [ + 218000, + 2250 + ], + "Classic UI SFX - Short - Low #1": [ + 222000, + 2010.4308390022538 + ], + "Classic UI SFX - Short - Low #10": [ + 226000, + 3020.8390022675644 + ], + "Classic UI SFX - Short - Low #11": [ + 231000, + 2458.3446712018144 + ], + "Classic UI SFX - Short - Low #12": [ + 235000, + 2901.0430839002197 + ], + "Classic UI SFX - Short - Low #13": [ + 239000, + 2843.7641723355964 + ], + "Classic UI SFX - Short - Low #14": [ + 243000, + 3135.4195011337824 + ], + "Classic UI SFX - Short - Low #15": [ + 248000, + 2703.1292517006877 + ], + "Classic UI SFX - Short - Low #16": [ + 252000, + 2875.011337868472 + ], + "Classic UI SFX - Short - Low #17": [ + 256000, + 2927.0975056689394 + ], + "Classic UI SFX - Short - Low #18": [ + 260000, + 3057.2789115646515 + ], + "Classic UI SFX - Short - Low #19": [ + 265000, + 2473.9455782312803 + ], + "Classic UI SFX - Short - Low #2": [ + 269000, + 2583.3333333333144 + ], + "Classic UI SFX - Short - Low #20": [ + 273000, + 2515.646258503409 + ], + "Classic UI SFX - Short - Low #21": [ + 277000, + 2604.172335600879 + ], + "Classic UI SFX - Short - Low #22": [ + 281000, + 3031.2698412698182 + ], + "Classic UI SFX - Short - Low #23": [ + 286000, + 2937.50566893425 + ], + "Classic UI SFX - Short - Low #24": [ + 290000, + 2609.387755102034 + ], + "Classic UI SFX - Short - Low #25": [ + 294000, + 2625.0113378685 + ], + "Classic UI SFX - Short - Low #3": [ + 298000, + 2828.140589569159 + ], + "Classic UI SFX - Short - Low #4": [ + 302000, + 2614.6031746031895 + ], + "Classic UI SFX - Short - Low #5": [ + 306000, + 3161.4739229024735 + ], + "Classic UI SFX - Short - Low #6": [ + 311000, + 2333.3333333333144 + ], + "Classic UI SFX - Short - Low #7": [ + 315000, + 2536.4625850340303 + ], + "Classic UI SFX - Short - Low #8": [ + 319000, + 2630.2267573695985 + ], + "Classic UI SFX - Short - Low #9": [ + 323000, + 2697.936507936504 + ], + "UI_Single_Set 16_01": [ + 327000, + 309.5918367346826 + ], + "UI_Single_Set 16_02": [ + 329000, + 309.5918367346826 + ], + "UI_Single_Set 16_03": [ + 331000, + 309.5918367346826 + ], + "UI_TwoNote_Set 15_01": [ + 333000, + 335.2380952380827 + ], + "UI_TwoNote_Set 15_02": [ + 335000, + 309.5918367346826 + ] + } +} \ No newline at end of file diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg new file mode 100644 index 0000000..b8e00d5 --- /dev/null +++ b/src/mainview/assets/sounds.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a3bb2f9a59e20e5ea49fec7fca68cda5c9167df332ff25d24c29870af834af7 +size 2229386 diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 2744e8e..8c42502 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,5 +1,5 @@ import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { useEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; export function AutoFocus (data: { parentKey?: string; @@ -8,7 +8,7 @@ export function AutoFocus (data: { delay?: number; }) { - useEffect(() => + useLayoutEffect(() => { let delayTimeout: number | undefined; diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index 7f5f2dc..885d073 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import useActiveControl from "../scripts/gamepads"; +import { oneShot } from "../scripts/audio/audio"; export function GameCardSkeleton () { @@ -38,10 +39,15 @@ export interface GameCardParams export default function CardElement (data: GameCardParams & InteractParams) { + const handleAction = () => + { + data.onAction?.(); + oneShot('click'); + }; const { ref, focused, focusSelf } = useFocusable({ focusKey: data.focusKey, onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), - onEnterPress: () => data.onAction?.(), + onEnterPress: handleAction, onBlur: () => data.onBlur?.(data.id), }); const { isPointer } = useActiveControl(); @@ -57,11 +63,10 @@ export default function CardElement (data: GameCardParams & InteractParams) scrollSnapAlign: isPointer ? "center" : "none" }} onFocus={focusSelf} - onDoubleClick={e => data.onAction?.(e.nativeEvent)} onClick={() => { focusSelf(); - data.onAction?.(); + handleAction(); }} className={twMerge( "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 8bb02a4..15b8d51 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -3,7 +3,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { CardList, GameMetaExtra } from "./CardList"; import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; -import { Router } from ".."; +import { useRouter } from "@tanstack/react-router"; export default function CollectionList (data: { id: string, @@ -14,12 +14,13 @@ export default function CollectionList (data: { saveChildFocus?: 'session' | 'local'; }) { + const router = useRouter(); const { data: collections } = useSuspenseQuery(getCollectionsQuery); const handleDefaultSelect = (gameId: string) => { const [source, id] = gameId.split('@'); - Router.navigate({ + router.navigate({ to: `/collection/$source/$id`, params: { source, id }, search: { countHint: collections.find(c => c.id.id === id && c.id.source === source)?.game_count } diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 35aae6f..ac0437f 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,18 +1,18 @@ -import { AnimatedBackground } from './AnimatedBackground'; -import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { HeaderUI, StickyHeaderUI } from './Header'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { StickyHeaderUI } from './Header'; import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense, useEffect } from 'react'; +import { JSX, Suspense } from 'react'; import Shortcuts from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; -import { HandleGoBack, useStickyDataAttr } from '../scripts/utils'; +import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; +import { useRouter } from '@tanstack/react-router'; export interface CollectionsDetailParams { @@ -29,6 +29,7 @@ export interface CollectionsDetailParams export function CollectionsDetail (data: CollectionsDetailParams) { + const router = useRouter(); const builtData = useQuery({ queryKey: ['filter', data.id], queryFn: async () => { @@ -42,7 +43,7 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); const { shortcuts } = useShortcutContext(); const handleScroll: GameCardFocusHandler = (cardId, node, details) => diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index bf9b757..94d31a8 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -6,6 +6,7 @@ import { X } from "lucide-react"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { ContextDialogContext } from "../scripts/contexts"; import { FOCUS_KEYS } from "../scripts/types"; +import { oneShot } from "../scripts/audio/audio"; export function ContextList (data: { options?: DialogEntry[]; @@ -34,6 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class { if (data.disabled === true) return; data.action?.({ close: context.close, focus: focusSelf }); + oneShot('click'); }; const { ref, focusSelf, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), @@ -57,6 +59,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class onClick={handleAction} data-selected={data.selected} aria-disabled={data.disabled} + data-sound-category={"menu"} className={ twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> @@ -100,10 +103,10 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla data.onClose?.(); if (newSourceFocusKey) { - setFocus(newSourceFocusKey); + setFocus(newSourceFocusKey, { instant: true }); } else if (sourceFocusKey) { - setFocus(sourceFocusKey); + setFocus(sourceFocusKey, { instant: true }); } } @@ -137,12 +140,14 @@ export function ContextDialog (data: { const handleClose = () => { data.close(false); + oneShot('closeContext'); }; useEffect(() => { if (data.open) { - focusSelf(); + focusSelf({ instant: true }); + oneShot('openContext'); } }, [data.open]); diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index c9f0266..6bafd95 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -1,20 +1,20 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import { Router } from ".."; import Shortcuts from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; -import { ErrorComponentProps } from "@tanstack/react-router"; +import { ErrorComponentProps, useRouter } from "@tanstack/react-router"; export default function Error (data: ErrorComponentProps) { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" }); - const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + const router = useRouter(); + const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); const { shortcuts } = useShortcutContext(); - useEffect(() => { focusSelf(); }, []); + useEffect(() => { focusSelf({ instant: true }); }, []); return
diff --git a/src/mainview/components/Filters.tsx b/src/mainview/components/Filters.tsx index 7060bff..1dea7b6 100644 --- a/src/mainview/components/Filters.tsx +++ b/src/mainview/components/Filters.tsx @@ -8,6 +8,7 @@ import SvgIcon from "./SvgIcon"; import { twMerge } from "tailwind-merge"; import { useEffect } from "react"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; function FilterCat ( data: { @@ -19,7 +20,10 @@ function FilterCat ( { const { ref, focusSelf } = useFocusable({ focusKey: data.id, - onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current, details), + onFocus: (l, p, details) => + { + data.onFocus?.(data.id, ref.current, details); + }, onEnterPress: data.onAction }); @@ -27,7 +31,8 @@ function FilterCat (
  • focusSelf({ event: e.nativeEvent })} + data-sound-category={data.active ? undefined : "filter"} className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"} > {data.icon ? <>
    {data.icon}
    {data.children ?? data.label}
    :
    {data.children ?? data.label}
    } @@ -68,6 +73,10 @@ export function FilterUI (data: { if (!data.options[newFilter].selected) { data.setSelected(newFilter); + oneShot('selectFilter'); + } else + { + oneShot('invalidNavigation'); } }, button: GamePadButtonCode.R1 @@ -80,7 +89,13 @@ export function FilterUI (data: { const selectedFilterIndex = Math.max(0, filterIndex - 1,); const newFilter = filterKeys[selectedFilterIndex]; if (!data.options[newFilter].selected) + { data.setSelected(newFilter); + oneShot('selectFilter'); + } else + { + oneShot('invalidNavigation'); + } }, button: GamePadButtonCode.L1 }], [data.options]); @@ -90,7 +105,7 @@ export function FilterUI (data: { { if (hasFocusedChild) { - setFocus(`${data.id}-${defaultFocus}`); + setFocus(`${data.id}-${defaultFocus}`, { instant: true }); } }, [hasFocusedChild, defaultFocus, data.id]); diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index 533eb29..39bd6ab 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -1,15 +1,16 @@ import { RPC_URL } from "@/shared/constants"; import CardElement from "./CardElement"; -import { Router } from ".."; import { FileQuestion, HardDrive, Store } from "lucide-react"; import { JSX } from "react"; import { FOCUS_KEYS } from "../scripts/types"; +import { useRouter } from "@tanstack/react-router"; export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams) { + const router = useRouter(); function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 1dad439..76b87b6 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -14,7 +14,6 @@ import Bell, Bluetooth, Clock, - Plug, Settings, Wifi, WifiHigh, @@ -23,17 +22,15 @@ import } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { RPC_URL, SystemInfoType } from "../../shared/constants"; import { JSX, RefObject, useContext, useEffect, useRef, useState } from "react"; -import { systemApi } from "../scripts/clientApi"; -import { Router } from ".."; import { useStickyDataAttr } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; import { TwitchIcon } from "../scripts/brandIcons"; -import { rommLoggedInQuery, rommUserQuery } from "../scripts/queries/romm"; +import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; -import { da } from "zod/v4/locales"; import { SystemInfoContext } from "../scripts/contexts"; +import { useRouter } from "@tanstack/react-router"; +import { oneShot } from "../scripts/audio/audio"; function HeaderAvatar (data: { id: string; @@ -206,19 +203,23 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) placeholderData: keepPreviousData }); - const { ref } = useFocusable({ focusKey: 'accounts' }); + const handleSelect = () => + { + router.navigate({ to: '/settings/accounts' }); + oneShot('click'); + }; + const { ref } = useFocusable({ + focusKey: 'accounts', onEnterPress: handleSelect + }); const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); + const router = useRouter(); if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ id: 'romm', preview: `https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg`, - action: () => - { - Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); - }, className: rommUser.data?.hasLogin && !rommUser.isError ? undefined : "border-error", type: 'secondary' }); @@ -228,15 +229,11 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { accounts.push({ id: 'twitch', preview: TwitchIcon, - action: () => - { - Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); - }, type: 'secondary' }); } - return
    + return
    {accounts?.map(a => { - Router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts' }); }; return ( @@ -296,7 +294,7 @@ export function HeaderUI (data: HeaderUIParams) className="flex items-center justify-between text-base-content" style={{ viewTimelineName: 'header' }} > - + {data.title} , id: "settings", action: goToSettings, external: true }]} /> diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index 5d2cb03..84db100 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -13,6 +13,7 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr }; const { ref, focusKey, focused } = useFocusable({ + focusable: !data.isFetching, focusKey: 'load-more-btn', onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), onEnterPress: handleAction diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index ea70534..0172729 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -1,19 +1,20 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import { Router } from ".."; import Shortcuts from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; +import { useRouter } from "@tanstack/react-router"; export default function NotFound () { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" }); - const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + const router = useRouter(); + const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); const { shortcuts } = useShortcutContext(); - useEffect(() => { focusSelf(); }, []); + useEffect(() => { focusSelf({ instant: true }); }, []); return
    diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index 3689b61..ae5af90 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -83,7 +83,7 @@ export default function Screenshots (data: { screenshots?: string[]; className?: const closest = findClosestElementToCenter(scrollRef.current); if (!closest) return; const closestIndex = Array.from(scrollRef.current.children).indexOf(closest); - setFocus(`screenshot-${closestIndex}`); + setFocus(`screenshot-${closestIndex}`, { instant: true }); } }, [focused, hasFocusedChild, scrollRef.current]); diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 3a7eb98..d931fa6 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -9,8 +9,7 @@ import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; import FocusTooltip from "../FocusTooltip"; -import { Router } from "@/mainview"; -import { useBlocker } from "@tanstack/react-router"; +import { useBlocker, useRouter } from "@tanstack/react-router"; function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) { @@ -35,11 +34,12 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); + const router = useRouter(); const deleteMutation = useMutation({ ...deleteGameMutation({ id: data.id, source: data.source }), onSuccess: (d, v, r, ctx) => { - ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => Router.history.back()); + ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => router.history.back()); }, onError (error) { diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 1e82ea1..e89f910 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -1,4 +1,3 @@ -import { Router } from "@/mainview"; import { rommApi } from "@/mainview/scripts/clientApi"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { JSX, useEffect, useRef, useState } from "react"; @@ -9,10 +8,12 @@ import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; +import { useRouter } from "@tanstack/react-router"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { const installMut = useMutation(installMutation(data.source, data.id)); + const router = useRouter(); const playMut = useMutation({ ...playMutation, onError (error) { @@ -20,7 +21,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }, onSuccess (data, { source, id }, onMutateResult, context) { - Router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); + router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); }, }); const ws = useRef<{ send: (data: string) => void; }>(undefined); @@ -58,10 +59,10 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { if (localId) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true }); } else { - Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); } }); } else if (e.data.status === 'error') @@ -78,7 +79,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so sub.close(); ws.current = undefined; }; - }, [data.source, data.id]); + }, [data.source, data.id, router]); let progressIcon: JSX.Element | undefined = undefined; switch (status) @@ -107,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (cmd.emulator === 'EMULATORJS') { const params = new URLSearchParams(cmd.command); - Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); + router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); } else { playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); @@ -142,7 +143,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { if (status === 'missing-emulator') { - Router.navigate({ to: '/settings/directories' }); + router.navigate({ to: '/settings/directories' }); } }} id="mainAction"> diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 1c6e63c..faef3fa 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -7,6 +7,7 @@ import import classNames from "classnames"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { CSSProperties } from "react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; @@ -35,9 +36,14 @@ export function Button (data: { tooltipType?: "base" | "accent" | "error"; } & InteractParams & FocusParams) { + const handleAction = (e?: any) => + { + data.onAction?.(e); + oneShot('click'); + }; const { ref, focused, focusKey } = useFocusable({ focusKey: data.id, - onEnterPress: data.onAction, + onEnterPress: () => handleAction(), onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), focusable: !data.disabled }); @@ -49,7 +55,7 @@ export function Button (data: { return + {open && ({ diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 1f43246..2181b93 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -4,6 +4,7 @@ import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionInput (data: { name: string; @@ -27,6 +28,7 @@ export function OptionInput (data: { { inputRef.current?.focus(); } + oneShot('click'); }; const { ref } = useFocusable({ focusKey: data.name, onEnterPress: handlePress @@ -79,12 +81,14 @@ export function OptionInput (data: { name={data.name} checked={Boolean(data.value)} type={data.type} + onClick={() => { oneShot("click"); }} autoComplete={data.autocomplete} onFocus={handleFocus} placeholder={data.placeholder} onChange={e => data.onChange?.(e.target.checked)} onBlur={data.onBlur} className={twMerge( + "active:bg-base-content rounded-full", data.className )} /> diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 29ba634..395767e 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -80,7 +80,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { const handleCloseSeatch = () => { setIsBrowsing(false); - setFocus(`${data.id}-browse`); + setFocus(`${data.id}-browse`, { instant: true }); }; const handleInputBlur = () => diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index a846406..93a0419 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -8,12 +8,12 @@ import { ChevronRight, Joystick } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils"; import FocusDots from "../FocusDots"; -import { Router } from "@/mainview"; import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; +import { useRouter } from "@tanstack/react-router"; -function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; }) +function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; }) { const { ref, focusKey } = useFocusable({ focusKey: data.id, @@ -39,6 +39,7 @@ export function EmulatorsSection (data: { header?: any; } & FocusParams) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id), trackChildren: true, @@ -68,7 +69,7 @@ export function EmulatorsSection (data: { scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' }); }} /> )) ?? Array.from({ length: 8 }).map((_, i) =>
    )} - Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> + router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 54f4057..843e8e7 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -30,7 +30,7 @@ export function GamesSection (data: { useEffect(() => { if (focused) - focusSelf(); + focusSelf({ instant: true }); }, [!!data.games]); return ( diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index b064ec8..fc5efd8 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -9,6 +9,7 @@ import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; +import { oneShot } from "@/mainview/scripts/audio/audio"; // ── Single missing-emulator card ─────────────────────────────────────────── interface MissingCardProps @@ -19,7 +20,11 @@ interface MissingCardProps function MissingCard ({ emulator: em, onSelect }: MissingCardProps) { - const handleSelect = () => onSelect?.(em.name, focusKey); + const handleSelect = () => + { + onSelect?.(em.name, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.MISSING_CARD(em.name), diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index ccf81cb..202c38c 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -9,6 +9,7 @@ import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Pa import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export const emulatorStatusIcons: Record = { store: , @@ -26,7 +27,11 @@ export function StoreEmulatorCard (data: { className?: string; }) { - const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey); + const handleSelect = () => + { + data.onSelect?.(data.emulator.name, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id), @@ -45,6 +50,7 @@ export function StoreEmulatorCard (data: { ref={ref} role="button" tabIndex={0} + data-sound-category="emulator" data-installed={data.emulator.validSources.some(s => s.exists)} onClick={isTouch ? handleSelect : undefined} className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} @@ -87,7 +93,7 @@ export function StoreEmulatorCard (data: {
    ; })} {isMouse && <> - + }
    diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index c76e8e9..f5639f9 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -9,15 +9,13 @@ import } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RPC_URL } from "../shared/constants"; import "./scripts/gamepads"; import "./scripts/windowEvents"; -import { client as rommClient } from "../clients/romm/client.gen"; import "./scripts/spatialNavigation"; import NotFound from "./components/NotFound"; import Error from "./components/Error"; import serviceWorker from './scripts/serviceWorker?worker&url'; -import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation"; +import App from "./App"; if ('serviceWorker' in navigator) { @@ -26,12 +24,6 @@ if ('serviceWorker' in navigator) const hashHistory = createHashHistory({}); -rommClient.setConfig({ - baseUrl: `${RPC_URL(__HOST__)}/api/romm`, - credentials: "include", - mode: "cors", -}); - const queryClient = new QueryClient(); export interface RouterContext @@ -66,25 +58,6 @@ export const Router = createRouter({ } }); -const focusMap = new Map(); -export const focusQueue: string[] = []; - -Router.history.subscribe((op) => -{ - if (op.action.type === 'PUSH') - { - focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey()); - } else if (op.action.type === 'BACK') - { - if (focusMap.has(op.location.state.__TSR_index)) - { - focusQueue.pop(); - focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); - focusMap.delete(op.location.state.__TSR_index); - } - } -}); - // Register things for typesafety declare module "@tanstack/react-router" { interface Register @@ -100,9 +73,11 @@ if (!rootElement.innerHTML) const root = createRoot(rootElement); root.render( - - - + + + + + , ); } diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 345a707..2687614 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,10 +4,7 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; -import { useEffect, useState } from "react"; -import { SystemInfoContext } from "../scripts/contexts"; -import { SystemInfoType } from "@/shared/constants"; -import { systemApi } from "../scripts/clientApi"; +import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; export const Route = createRootRouteWithContext()({ diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index 9d67605..7b7fa7d 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -1,9 +1,8 @@ import { RPC_URL, SERVER_URL } from '@/shared/constants'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; -import { Router } from '..'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; @@ -57,7 +56,7 @@ function Overlay (data: { { if (data.open) { - focusSelf(); + focusSelf({ instant: true }); } }, [data.open]); @@ -122,6 +121,7 @@ function Frame (data: { ref: RefObject; }) function RouteComponent () { + const router = useRouter(); const { ref, focusSelf, focusKey } = useFocusable({ focusKey: 'emulatorjs', preferredChildFocusKey: 'frame', @@ -133,7 +133,7 @@ function RouteComponent () function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); } useEventListener('message', e => @@ -173,7 +173,7 @@ function RouteComponent () }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); const { shortcuts } = useShortcutContext(); - useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]); + useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]); function handleClose () { setOverlayOpen(false); diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 6f97fbc..7147e79 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,16 +1,15 @@ -import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; +import { createFileRoute, ErrorComponentProps, useRouter, useRouterState } from "@tanstack/react-router"; import { RPC_URL } from "@shared/constants"; import { useEffect, useRef, useState } from "react"; -import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; -import { Router } from "../.."; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; -import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack, useStickyDataAttr } from "@/mainview/scripts/utils"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; @@ -21,7 +20,7 @@ import Achievements from "@/mainview/components/game/Achievements"; import { GameDetailsContext } from "@/mainview/scripts/contexts"; import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; -import Details, { DetailElement } from "@/mainview/components/game/Details"; +import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute("/game/$source/$id")({ @@ -31,7 +30,11 @@ export const Route = createFileRoute("/game/$source/$id")({ }, component: RouteComponent, errorComponent: Error, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + staticData: { + enterSound: 'openDetails', + goBackSound: "returnDetails" + }, }); function useDetailsSection () @@ -45,10 +48,6 @@ function Error (data: ErrorComponentProps) useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - focusSelf(); - }, []); return
    @@ -68,6 +67,7 @@ function Error (data: ErrorComponentProps)
    + ; } @@ -139,10 +139,10 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: Fr function RouteComponent () { + const router = useRouter(); const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false); const { source, id } = Route.useParams(); const { data } = useQuery(gameQuery(source, id)); - const { focus } = Route.useSearch(); const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); @@ -150,7 +150,12 @@ function RouteComponent () const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ + label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) + }], [router]); + + useOnNavigateBack((s) => s.sound = 'returnDetails'); + const { shortcuts } = useShortcutContext(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -190,7 +195,7 @@ function RouteComponent () onFocus={scrollIntoViewHandler({ block: 'center' })} onSelect={(id, focus) => { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} emulators={recommendedEmulators} />} @@ -206,7 +211,7 @@ function RouteComponent ()
    { - Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} />
  • diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 945b6d7..d285464 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -14,6 +14,7 @@ import import { createFileRoute, + useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import @@ -37,7 +38,6 @@ import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; -import { Router } from ".."; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, useDragScroll } from "../scripts/utils"; @@ -45,6 +45,7 @@ import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; +import { oneShot } from "../scripts/audio/audio"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -90,9 +91,10 @@ function HomeListError (data: { focused: boolean; }) function ShowAllGamesCard () { + const router = useRouter(); const handleNavigate = () => { - Router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); }; const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); return
    All Games
    ; @@ -102,6 +104,7 @@ function HomeList (data: { selectedFilter: string; }) { + const router = useRouter(); const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); @@ -124,7 +127,7 @@ function HomeList (data: { function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; let activeList: JSX.Element; @@ -213,9 +216,11 @@ function HomeList (data: { function MainMenu () { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: `main-menu`, trackChildren: true, + focusBoundaryDirections: ['up', 'down'] }); return (
      Router.navigate({ to: "/games" })} + action={() => router.navigate({ to: "/games" })} icon={} label="Home" type="secondary" /> } label="News" /> - } action={() => Router.navigate({ to: "/store/tab" })} label="Shop" /> + } action={() => router.navigate({ to: "/store/tab" })} label="Shop" /> } label="Album" /> } @@ -241,7 +246,7 @@ function MainMenu () { - Router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts' }); }} icon={} label="Settings" @@ -259,11 +264,16 @@ function CircleIcon (data: { icon?: JSX.Element; }) { + const handleAction = () => + { + data.action?.(); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ - focusKey: `navigation-icon-${data.label}`, - onEnterPress: data.action, + focusKey: `menu-navigation-icon-${data.label}`, + onEnterPress: handleAction, }); - useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]); + useShortcuts(focusKey, () => [{ label: data.label, action: handleAction, button: GamePadButtonCode.A }]); const typeClasses = { secondary: "bg-secondary text-secondary-content", accent: "bg-accent text-accent-content", @@ -273,7 +283,8 @@ function CircleIcon (data: { return (
    • @@ -287,7 +298,7 @@ export default function ConsoleHomeUI () const { filter } = Route.useSearch(); const close = useMutation(closeMutation); - + const router = useRouter(); const { ref, focusKey } = useFocusable({ forceFocus: true, autoRestoreFocus: false, @@ -296,7 +307,7 @@ export default function ConsoleHomeUI () preferredChildFocusKey: `home-list`, }); - const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); + const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); const { shortcuts } = useShortcutContext(); const headerButtons: HeaderButton[] = []; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 89c4a65..49011d7 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,7 +1,6 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; -import { Router } from '..'; import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; @@ -16,9 +15,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({ function RouteComponent () { + const router = useRouter(); function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); } const { source, id } = Route.useParams(); diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 7c0faf5..841702b 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -171,7 +171,7 @@ function RouteComponent () { if (focus) { - focusSelf(); + focusSelf({ instant: true }); } }, [focus]); diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 4d7c177..b5e25c8 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -19,7 +19,6 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; -import { Router } from '@/mainview'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -76,7 +75,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd const handleCloseContext = () => { setNewEmulatorTypeOpen(false); - setFocus('emulator'); + setFocus('emulator', { instant: true }); }; @@ -123,7 +122,7 @@ function EmulatorPath (data: { id: string; }) const handleCloseSearch = () => { setIsSearching(false); - setFocus(`search-${data.id}`); + setFocus(`search-${data.id}`, { instant: true }); }; const handleSelectPath = (path: string) => @@ -192,6 +191,7 @@ function EmulatorBadge (data: { addOverride: (emulator: string) => void; } & FocusParams) { + const router = useRouter(); const { focusKey, ref, focused } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_CARD(data.emulator.name), onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); } @@ -212,12 +212,12 @@ function EmulatorBadge (data: { label: "Visit Store", action () { - Router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }, }); } return shortcuts; - }, [data.addOverride]); + }, [data.addOverride, router]); let statusIcon = ; @@ -255,7 +255,7 @@ function EmulatorBadge (data: { case 'store': icon = ; className = "hover:bg-base-content hover:text-base-100 cursor-pointer bg-accent text-accent-content"; - action = () => { Router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }; + action = () => { router.navigate({ to: '/store/details/emulator/$id', params: { id: data.emulator.name } }); }; break; case 'embedded': icon = ; diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index c1c94f9..ddca3a8 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -19,6 +19,7 @@ function RouteComponent () +
    ; } diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index c6e8198..5c76442 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -8,6 +8,7 @@ import Outlet, createFileRoute, useMatch, + useRouter, } from "@tanstack/react-router"; import { ViewTransitionOptions } from "@tanstack/router-core"; import classNames from "classnames"; @@ -21,20 +22,24 @@ import MonitorCog, Puzzle, } from "lucide-react"; -import { JSX, useEffect } from "react"; +import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import z from "zod"; import { SettingsSchema } from "../../../shared/constants"; -import { Router } from "../.."; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export const Route = createFileRoute("/settings")({ component: SettingsUI, validateSearch: z.object({ focus: z.keyof(SettingsSchema).optional() - }) + }), + staticData: { + enterSound: 'openSettings' + } }); function MenuItem (data: { @@ -48,17 +53,18 @@ function MenuItem (data: { label: string; }) { + const router = useRouter(); const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; const handleNonFocusSelect = () => { if (data.return) { - HandleGoBack(); + HandleGoBack(router); } else if (!acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } - + oneShot('click'); }; const { ref, focusSelf } = useFocusable({ focusKey: `menu-item-${data.route}`, @@ -67,7 +73,7 @@ function MenuItem (data: { { if (data.focusSelect && !acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } (ref.current as HTMLElement).scrollIntoView({ inline: 'center' }); }, @@ -81,6 +87,7 @@ function MenuItem (data: {
  • - { - focusSelf(); - }, []); - - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); const { shortcuts } = useShortcutContext(); return ( @@ -196,6 +199,7 @@ export function SettingsUI () + ); } diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 1593bd9..9ad4678 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -4,9 +4,8 @@ import useFocusable, FocusContext, } from "@noriginmedia/norigin-spatial-navigation"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { Router } from "@/mainview"; import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { systemApi } from "@/mainview/scripts/clientApi"; @@ -18,7 +17,7 @@ import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; -import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useJobStatus, useOnNavigateBack } from "@/mainview/scripts/utils"; import toast from "react-hot-toast"; import { getErrorMessage } from "react-error-boundary"; import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard"; @@ -27,6 +26,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store"; import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm"; import FocusTooltip from "@/mainview/components/FocusTooltip"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, @@ -35,6 +35,10 @@ export const Route = createFileRoute('/store/details/emulator/$id')({ ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id)); + }, + staticData: { + enterSound: "openDetails", + goBackSound: "returnDetails" } }); @@ -288,7 +292,7 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; }) export function RouteComponent () { const { id } = Route.useParams(); - + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `GAME_DETAIL_${id}`, trackChildren: true, @@ -301,22 +305,16 @@ export function RouteComponent () useShortcuts(focusKey, () => [{ label: "Return", - action: HandleGoBack, + action: () => HandleGoBack(router), button: GamePadButtonCode.B - }]); + }], [router]); const installMutation = useMutation({ ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); - useEffect(() => - { - focusSelf(); - }, []); - const { shortcuts } = useShortcutContext(); - const stats: StatEntry[] = []; if (emulator) { @@ -341,6 +339,7 @@ export function RouteComponent () return ( +
    @@ -370,7 +369,7 @@ export function RouteComponent () onFocus={scrollIntoViewHandler({ block: 'center' })} onSelect={(id, focus) => { - Router.navigate({ + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} @@ -386,7 +385,7 @@ export function RouteComponent ()
    { - Router.navigate({ + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} games={recommendedGames} />} diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index e656b8e..2f8f091 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,4 +1,4 @@ -import { Router } from '@/mainview'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; import Shortcuts from '@/mainview/components/Shortcuts'; @@ -6,19 +6,21 @@ import { StoreContext } from '@/mainview/scripts/contexts'; import { gameQuery } from '@/mainview/scripts/queries/romm'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts'; -import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; +import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { useMatchRoute } from '@tanstack/react-router'; +import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { Settings } from 'lucide-react'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + staticData: { + enterSound: 'openStore' + } }); function useIsSettings (subPath: string) @@ -33,6 +35,7 @@ function useIsSettings (subPath: string) function TopArea (data: { filters: Record; }) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: 'top-area', preferredChildFocusKey: `store-tabs`, @@ -44,13 +47,13 @@ function TopArea (data: { filters: Record; }) useShortcuts("STORE_ROOT", () => [{ label: "Return", - action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }), + action: () => HandleGoBack(router), button: GamePadButtonCode.B - }], []); + }], [router]); const handleNavigate = (s: string) => { - Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); }; return
    @@ -76,6 +79,7 @@ function StoreOutlet () function RouteComponent () { // Root spatial nav container + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "STORE_ROOT", preferredChildFocusKey: 'top-area', @@ -93,25 +97,16 @@ function RouteComponent () const { shortcuts } = useShortcutContext(); const { focus } = Route.useSearch(); - useEffect(() => - { - if (!focus) - { - focusSelf(); - } - }, []); - const handleDetails = (type: string, source: string, id: string, focus: string) => { if (type === 'emulator') { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); } else if (type === 'game') { - Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); + router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); } - }; const handlePrefetch = (type: string, source: string, id: string) => @@ -150,5 +145,6 @@ function RouteComponent ()
    + ; } diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts new file mode 100644 index 0000000..6084ac4 --- /dev/null +++ b/src/mainview/scripts/audio/audio.ts @@ -0,0 +1,70 @@ +import { Howl } from 'howler'; +import sounds from '../../assets/sounds.ogg'; +import soundSprites from '../../assets/sounds.json'; +import { getLocalSetting } from '../utils'; + +const timingMap = new Map(); + +const sound = new Howl({ + src: [sounds], + sprite: soundSprites.sprite as any, + volume: 0.5, +}); +import.meta.hot?.dispose(() => { sound.unload(); }); + +declare module '@tanstack/react-router' { + interface StaticDataRouteOption + { + enterSound?: keyof typeof soundMap | null; + goBackSound?: keyof typeof soundMap | null; + } +} + +const volumeVariation = 0.05; +const rateVariation = 0.01; + +export const soundMap = { + openDetails: { key: 'Classic UI SFX - Chords #1' }, + returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, + returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, + openGeneric: { key: 'Classic UI SFX - Short - High #9' }, + select: { key: 'Classic UI SFX - Short - High #5', rateVariation, volumeVariation }, + selectAlt: { key: "Classic UI SFX - Short - High #6", rateVariation, volumeVariation }, + selectMenu: { key: 'Classic UI SFX - Short - High #7', rateVariation, volumeVariation }, + selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, + closeContext: { key: 'Classic UI SFX - Short - High #19' }, + openContext: { key: 'Classic UI SFX - Short - High #22' }, + openStore: { key: 'Classic UI SFX - Chords #16' }, + openSettings: { key: 'Classic UI SFX - Short - High #8' }, + click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, + clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, + invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, +} satisfies Record; + +function sinRanom () +{ + return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); +} + +function cosRandom () +{ + return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); +} + +function random () +{ + return Math.random() * 2 - 1; +} + +export function oneShot (id: keyof typeof soundMap) +{ + const currentDate = timingMap.get(id); + if (!getLocalSetting('soundEffects')) return; + if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return; + const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }; + const instanceId = sound.play(soundValue.key); + sound.volume(sound.volume() + random() * (soundValue.volumeVariation ?? 0), instanceId); + sound.rate(1 + random() * (soundValue.rateVariation ?? 0), instanceId); + timingMap.set(id, new Date()); +} + diff --git a/src/mainview/scripts/audio/audioCallbacks.ts b/src/mainview/scripts/audio/audioCallbacks.ts new file mode 100644 index 0000000..4a8f744 --- /dev/null +++ b/src/mainview/scripts/audio/audioCallbacks.ts @@ -0,0 +1,90 @@ +import { Router } from "@/mainview"; +import { oneShot, soundMap } from "./audio"; +export default function load () +{ + let lastLocationPath: string | undefined; + const unsub = Router.history.subscribe((op) => + { + if (op.action.type === 'PUSH') + { + lastLocationPath = op.location.pathname; + + const routes = Router.matchRoutes(op.location.pathname); + const soundRoute = routes.find(r => r.staticData.enterSound !== undefined); + if (soundRoute) + { + if (soundRoute.staticData.enterSound) oneShot(soundRoute.staticData.enterSound); + } else + { + oneShot("openGeneric"); + } + + } else if (op.action.type === 'BACK') + { + if (lastLocationPath) + { + const soundRoutes = Router.matchRoutes(lastLocationPath); + const soundRoute = soundRoutes.find(r => r.staticData.goBackSound !== undefined); + if (soundRoute) + { + if (soundRoute.staticData.goBackSound) oneShot(soundRoute.staticData.goBackSound); + } else + { + oneShot("returnGeneric"); + } + } else + { + oneShot("returnGeneric"); + } + + lastLocationPath = op.location.state.key; + } + }); + + let focusChangeDebounced: undefined | NodeJS.Timeout; + + const focuschangedHandler = (e: CustomEvent) => + { + clearTimeout(focusChangeDebounced); + if (!e.detail.focusKeyChanged) return; + + if (e.detail.nativeEvent || e.detail.event) + { + let sound: keyof typeof soundMap; + if (e.detail.node && e.detail.node.matches('[data-sound-category="menu"]')) + { + sound = 'selectMenu'; + + } else if (e.detail.node && e.detail.node.matches('[data-sound-category="filter"]')) + { + sound = "selectFilter"; + } + else if (e.detail.node && e.detail.node.matches('[data-sound-category="emulator"]')) + { + sound = "selectAlt"; + } + else if (!e.detail.node || !e.detail.node.matches('[data-sound-disable="focus"]')) + { + sound = e.detail.sound as any ?? 'select'; + } + + setTimeout(() => + { + if (e.detail.nativeEvent || e.detail.event) + { + oneShot(sound); + } + }, 10); + } + }; + + window.addEventListener('focuschanged', focuschangedHandler as any); + + return { + cleanup: () => + { + unsub(); + window.removeEventListener('focuschanged', focuschangedHandler as any); + } + }; +} \ No newline at end of file diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index fed3f3d..e7edf3b 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -2,6 +2,7 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-s import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; import { mobileCheck } from "./utils"; +import { oneShot } from "./audio/audio"; let loopStarted = false; let isTouching = false; @@ -104,7 +105,10 @@ function throttleNav (key: string, dir: string, event: Event) const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6), minSpeed); if ((currentDate.getTime() - (lastTime ?? 0) > speed)) { + const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); + if (currentFocusKey === getCurrentFocusKey()) + oneShot('invalidNavigation'); throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 3d4b182..3129f2a 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -1,6 +1,5 @@ import { - FocusDetails, getCurrentFocusKey, init, SpatialNavigation, @@ -9,7 +8,7 @@ import UseFocusableResult, } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useState } from "react"; -import { focusQueue, Router } from ".."; +import { focusQueue } from "../App"; init({ shouldFocusDOMNode: false, @@ -97,13 +96,21 @@ SpatialNavigation.updateLayout = (focusKey) => SpatialNavigation.setFocus = (newFocusKey, focusDetails) => { setFocus(newFocusKey, focusDetails); - dispatchFocusedEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); }; SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => { + const details: FocusEventDetails = { + ...focusDetails, + focusKey: newFocusKey, + focusKeyChanged: newFocusKey !== getCurrentFocusKey(), + node: GetFocusedElement(newFocusKey) + }; setCurrentFocusedKey(newFocusKey, focusDetails); - window.dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); + window.dispatchEvent(new CustomEvent('focuschanged', { + bubbles: true, + detail: details + })); }; SpatialNavigation.updateFocusable = (key, data) => diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index fd2572b..a9666be 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -3,7 +3,8 @@ import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; -import { Router } from ".."; +import { AnyRouter, Router, useRouter } from "@tanstack/react-router"; +import { soundMap } from "./audio/audio"; export type ScrollSaveParams = { id: string; @@ -59,6 +60,13 @@ export function mobileCheck () return check; }; +export function getLocalSetting (key: TKey) +{ + const localValueRaw = localStorage.getItem(key); + if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined); + return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)); +} + export function useLocalSetting (key: TKey) { const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) }); @@ -218,7 +226,7 @@ export function scrollIntoViewHandler (params?: ScrollIntoViewOptions) return (focusKey: string, node: HTMLElement, details: any) => { if (details.nativeEvent instanceof PointerEvent) return; - node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' }); + node.scrollIntoView({ ...params, behavior: details.instant || !details.event ? 'instant' : 'smooth' }); }; } @@ -315,13 +323,37 @@ export function useJobStatus) => void) +{ + const router = useRouter(); + const prevIndex = useRef(router.history.location.state.__TSR_index); + + useEffect(() => + { + const unsub = router.history.subscribe(() => + { + const currentIndex = router.history.location.state.__TSR_index; + const isBack = currentIndex < prevIndex.current; + + if (isBack) + { + callback(router.history.location.state); + } + + prevIndex.current = currentIndex; + }); + + return unsub; + }, [router]); } \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 699ca3c..00aca5e 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -16,6 +16,25 @@ declare global "save-scroll"?: boolean; } } + + module "@noriginmedia/norigin-spatial-navigation" { + declare interface FocusDetails + { + instant?: boolean; + sound?: string; + } + } +} + +declare interface FocusEventDetails +{ + focusKey: string; + instant?: boolean; + sound?: string; + nativeEvent?: any; + event?: Event; + node: HTMLElement | undefined; + focusKeyChanged: boolean; } declare interface FocusParams diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 66e3a78..acced4c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -41,7 +41,8 @@ export const SettingsSchema = z.object({ export const LocalSettingsSchema = z.object({ backgroundBlur: z.stringbool().or(z.boolean()).default(true), backgroundAnimation: z.stringbool().or(z.boolean()).default(true), - theme: z.enum(['dark', 'light', 'auto']).default('auto') + theme: z.enum(['dark', 'light', 'auto']).default('auto'), + soundEffects: z.boolean().default(true) }); export const GameListFilterSchema = z.object({ diff --git a/src/sounds/Classic UI SFX - Chords #1.wav b/src/sounds/Classic UI SFX - Chords #1.wav new file mode 100644 index 0000000..eef68fe --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cce497234f22db3e9d1c0e720c36242b3e0d68a8e5ebed0d99df9dbfb5a7ac85 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #10.wav b/src/sounds/Classic UI SFX - Chords #10.wav new file mode 100644 index 0000000..dff9ef9 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5e37d4692690781115bef69f033c371c89fc5e3be3415c01bcfb47f5b03c9f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #11.wav b/src/sounds/Classic UI SFX - Chords #11.wav new file mode 100644 index 0000000..975f24d --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61c21f7ec7440719aef486730104cc1110c881de5a38e58151101088b7f63e89 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #12.wav b/src/sounds/Classic UI SFX - Chords #12.wav new file mode 100644 index 0000000..b6db6ee --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80bbc3cddf2f040a96a989e4febf66885a031a42438e705267ecad60e69caf23 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #13.wav b/src/sounds/Classic UI SFX - Chords #13.wav new file mode 100644 index 0000000..bdd6474 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:532526fee3cea70f093cceef20d1a2587e334f13c293461c152b9e4faf5c8ab5 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #14.wav b/src/sounds/Classic UI SFX - Chords #14.wav new file mode 100644 index 0000000..86c4cf1 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca7fadf4951a5beace5c5db4a24fd86f8907d0071a7f9a3bc600e973399b001f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #15.wav b/src/sounds/Classic UI SFX - Chords #15.wav new file mode 100644 index 0000000..40c31fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54ed638f5ecca2615e0ac4bd42031efa89de810f18b0b3c0b0f2920bfaf021f6 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #16.wav b/src/sounds/Classic UI SFX - Chords #16.wav new file mode 100644 index 0000000..c019363 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e67390430ce0b19689f5322ad6ff78cd0a7f01cea6b55d02fef19c72ea77a653 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #17.wav b/src/sounds/Classic UI SFX - Chords #17.wav new file mode 100644 index 0000000..e3f84cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90fe98d297a58235bda0a9a4bfc367914f052a550a7289fe652617f30f0b5e32 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #18.wav b/src/sounds/Classic UI SFX - Chords #18.wav new file mode 100644 index 0000000..8883284 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb19e2996301ccfcfdf328c2f2b553ff9aa0dfe1ff4982ae1c54c9d6a2ba2438 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #19.wav b/src/sounds/Classic UI SFX - Chords #19.wav new file mode 100644 index 0000000..0edb657 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31630542c8751e4fcdd0a9ab211816f09aea6c4bf6069694e291b18b0774d7df +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #2.wav b/src/sounds/Classic UI SFX - Chords #2.wav new file mode 100644 index 0000000..4b38c6b --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc42fc6845b2ef1ee3db50d81335e93e97902cde85fcf8fe3e5ee29c83163e9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #20.wav b/src/sounds/Classic UI SFX - Chords #20.wav new file mode 100644 index 0000000..7d61fa7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52d3abeb064f3e749d0f4fd29f92b19e54e1477ec80155aca906ae8975e2709f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #3.wav b/src/sounds/Classic UI SFX - Chords #3.wav new file mode 100644 index 0000000..a9778fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1295f494ec9d295710e7bb1cf467fb118e102891dc9009b86b0ae9960d32e382 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #4.wav b/src/sounds/Classic UI SFX - Chords #4.wav new file mode 100644 index 0000000..b2f258e --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5e387f33652387eda2189fc9f6c2194b36a9aac272b36ccc64c4e468b77e214 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #5.wav b/src/sounds/Classic UI SFX - Chords #5.wav new file mode 100644 index 0000000..6f2dc11 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:739c571b6b1ab60a002b9b357878ae91bb9df576d52e5480f07c17886a53f805 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #6.wav b/src/sounds/Classic UI SFX - Chords #6.wav new file mode 100644 index 0000000..fc36385 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d80b6464291c88b7f33748d4a4e93ed07ee1756d5c623b3862bd3847767d7b54 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #7.wav b/src/sounds/Classic UI SFX - Chords #7.wav new file mode 100644 index 0000000..33f80af --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:796918fa72911e50c7d762f0fb599427c5924d53b627a1b735f16ca1a9f54fe3 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #8.wav b/src/sounds/Classic UI SFX - Chords #8.wav new file mode 100644 index 0000000..047aa68 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0439d8fbe9c4734acd7b40e440027d2a7a1c1ae20218f1ca2e3dacd318d776f9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #9.wav b/src/sounds/Classic UI SFX - Chords #9.wav new file mode 100644 index 0000000..7368828 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe8e930484f41092b1518f0f3839b617561f1c62ce4b7532158c0cf484fd4d6f +size 769182 diff --git a/src/sounds/Classic UI SFX - Short - High #1.wav b/src/sounds/Classic UI SFX - Short - High #1.wav new file mode 100644 index 0000000..a29dbce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa96866433176c69bf23254badee5abd87741816fe833a07a26d6031020464a2 +size 489182 diff --git a/src/sounds/Classic UI SFX - Short - High #10.wav b/src/sounds/Classic UI SFX - Short - High #10.wav new file mode 100644 index 0000000..8c9c510 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e58d993037d6c05797c1ecadd738f484d2c1428db6d96381522ef1254b6f794f +size 490182 diff --git a/src/sounds/Classic UI SFX - Short - High #11.wav b/src/sounds/Classic UI SFX - Short - High #11.wav new file mode 100644 index 0000000..558786a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d648dc7030de8bc59abc70a0153e37b08cf4267228aab41a321eeaa3f03fb2fc +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #12.wav b/src/sounds/Classic UI SFX - Short - High #12.wav new file mode 100644 index 0000000..cbd310c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b4c11124a6a9542a3e672e4707d9dd67ea5f805ec239c310218c53d61634d4e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #13.wav b/src/sounds/Classic UI SFX - Short - High #13.wav new file mode 100644 index 0000000..fa8a598 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5911be904de2655606bd16834391d21f56313d6fce28ed5cfaaf560331a4be80 +size 576182 diff --git a/src/sounds/Classic UI SFX - Short - High #14.wav b/src/sounds/Classic UI SFX - Short - High #14.wav new file mode 100644 index 0000000..e7e21ca --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:815699bd8283ecd8af0cdfcf384a97a6a61a8aa6ab9400d0fa079267eee0567e +size 538182 diff --git a/src/sounds/Classic UI SFX - Short - High #15.wav b/src/sounds/Classic UI SFX - Short - High #15.wav new file mode 100644 index 0000000..142bc6f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b2b932aade06ea1575755fa516aec836224397145d6b22270096ae74078b7c +size 523182 diff --git a/src/sounds/Classic UI SFX - Short - High #16.wav b/src/sounds/Classic UI SFX - Short - High #16.wav new file mode 100644 index 0000000..d37ab0c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8340972a758f262ff71b319a43bcdebb685d3c7036a169276b095b726c22000b +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #17.wav b/src/sounds/Classic UI SFX - Short - High #17.wav new file mode 100644 index 0000000..112b03e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fbe6ca64226581e8bafa0891fabfd465d9045b395c4a21534a04af435342971 +size 553182 diff --git a/src/sounds/Classic UI SFX - Short - High #18.wav b/src/sounds/Classic UI SFX - Short - High #18.wav new file mode 100644 index 0000000..34ef574 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95539682288858aaf0b37bc35715b71060e76bf15b81db3898a0f739f062b243 +size 453182 diff --git a/src/sounds/Classic UI SFX - Short - High #19.wav b/src/sounds/Classic UI SFX - Short - High #19.wav new file mode 100644 index 0000000..b165ece --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d312f707829b975fb625bed15879118479a7927d9e29bd62d2c1c716d35b367f +size 586182 diff --git a/src/sounds/Classic UI SFX - Short - High #2.wav b/src/sounds/Classic UI SFX - Short - High #2.wav new file mode 100644 index 0000000..f6d5fa2 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89c235075af8db515f97c2bfd50f5350c28c2ba2079e6fad8fdfb963f24a12a1 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - High #20.wav b/src/sounds/Classic UI SFX - Short - High #20.wav new file mode 100644 index 0000000..7e23a1e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61a585819286d0f11314813ee89bc8f121ca4b362d1721e2edfe14172a6358e7 +size 387182 diff --git a/src/sounds/Classic UI SFX - Short - High #21.wav b/src/sounds/Classic UI SFX - Short - High #21.wav new file mode 100644 index 0000000..49cb131 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:480c9e31c1a033c959888bd2a2691c772ef80bbec1c5f8dc3fccb7e86da6c153 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #22.wav b/src/sounds/Classic UI SFX - Short - High #22.wav new file mode 100644 index 0000000..acced97 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ab262921ec2763a84c25d3ec00e1ade68e96393fbd475c28659aa661af9d41f +size 478182 diff --git a/src/sounds/Classic UI SFX - Short - High #23.wav b/src/sounds/Classic UI SFX - Short - High #23.wav new file mode 100644 index 0000000..4d106a7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0f09d67685a7cf0b82b66f082ad77cf0b536557306fbf6862ba3ef0b92b8280 +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - High #24.wav b/src/sounds/Classic UI SFX - Short - High #24.wav new file mode 100644 index 0000000..e3d0dc8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd3520550af14b8d19b275bba18fb7f2610f156e6eea780837748a068d6a858d +size 402182 diff --git a/src/sounds/Classic UI SFX - Short - High #25.wav b/src/sounds/Classic UI SFX - Short - High #25.wav new file mode 100644 index 0000000..6632b69 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f18ca1455d31a7c430572c64b85c0c600a0c77986e6411a0f9cff783445393c3 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #3.wav b/src/sounds/Classic UI SFX - Short - High #3.wav new file mode 100644 index 0000000..767c819 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:140b58fa531bb20dcb284e8babea35ef55fa5d58aff16e8d98b505ccf02115e5 +size 550182 diff --git a/src/sounds/Classic UI SFX - Short - High #4.wav b/src/sounds/Classic UI SFX - Short - High #4.wav new file mode 100644 index 0000000..5eb2d78 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d11499b350e8e95990d298dc96ce01026973ccf5bd58891c9b505d3ba32b1a82 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - High #5.wav b/src/sounds/Classic UI SFX - Short - High #5.wav new file mode 100644 index 0000000..8cc1019 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e92d3e60c23ea4927444a28174a53fcb668d83f850df2494f53d5870613143a +size 499182 diff --git a/src/sounds/Classic UI SFX - Short - High #6.wav b/src/sounds/Classic UI SFX - Short - High #6.wav new file mode 100644 index 0000000..3e496cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ec68d1b1549fed1bccaf62ea4fd80c5c59b9b50750fa324b858e1c370344270 +size 466182 diff --git a/src/sounds/Classic UI SFX - Short - High #7.wav b/src/sounds/Classic UI SFX - Short - High #7.wav new file mode 100644 index 0000000..8fae195 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f2dd1c42c354343f80c631721c8dceadb80b8e03ecec4e93294c8ffb21cfcf +size 474182 diff --git a/src/sounds/Classic UI SFX - Short - High #8.wav b/src/sounds/Classic UI SFX - Short - High #8.wav new file mode 100644 index 0000000..a25618d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683fc09fbf580efa6990504bd17861f48495555ac81f4ed2b9e85f14628348fe +size 560182 diff --git a/src/sounds/Classic UI SFX - Short - High #9.wav b/src/sounds/Classic UI SFX - Short - High #9.wav new file mode 100644 index 0000000..9ee3b5f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b60cd47d7591fc5e2cf49c8139781479d3833d621bba129903d78843d7929380 +size 432182 diff --git a/src/sounds/Classic UI SFX - Short - Low #1.wav b/src/sounds/Classic UI SFX - Short - Low #1.wav new file mode 100644 index 0000000..dba2e40 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:419e970729e384fcba3a9c4bb3c55cec906dfd20f969c1cf0cbf2ffcbc00638e +size 386182 diff --git a/src/sounds/Classic UI SFX - Short - Low #10.wav b/src/sounds/Classic UI SFX - Short - Low #10.wav new file mode 100644 index 0000000..4eebb5b --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9749c07cacebb0bfe9924bbf8f0142630c96c0d4d5d0e727f5daeb354f9505a8 +size 580182 diff --git a/src/sounds/Classic UI SFX - Short - Low #11.wav b/src/sounds/Classic UI SFX - Short - Low #11.wav new file mode 100644 index 0000000..d4cbb0d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4b7c87005eae8d0b0d329329135a67cd318a775cf2c75dd5ae68974537f9b7c +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - Low #12.wav b/src/sounds/Classic UI SFX - Short - Low #12.wav new file mode 100644 index 0000000..65b3517 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96018d0ee84d1e27e0fc655c03d4afd3fa0839aecca09fe7ef2f0f104f424e60 +size 557182 diff --git a/src/sounds/Classic UI SFX - Short - Low #13.wav b/src/sounds/Classic UI SFX - Short - Low #13.wav new file mode 100644 index 0000000..ac2b8bf --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a3de21306c039abc81ace6a9e2e18f581c4993c532c8120126df055be7c9767 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - Low #14.wav b/src/sounds/Classic UI SFX - Short - Low #14.wav new file mode 100644 index 0000000..ce57f07 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848195440e0a64566d175a62aa13685872fdb012bf491e5742984d5059524f5f +size 602182 diff --git a/src/sounds/Classic UI SFX - Short - Low #15.wav b/src/sounds/Classic UI SFX - Short - Low #15.wav new file mode 100644 index 0000000..e8218d5 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee89025f7fb8517deb610d2814b941136fda09ebcbbacc78cddfe834c5b348a +size 519182 diff --git a/src/sounds/Classic UI SFX - Short - Low #16.wav b/src/sounds/Classic UI SFX - Short - Low #16.wav new file mode 100644 index 0000000..187b096 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a0bafde7f6a2b75589faac13b7d2b929239189ebc554c338fd2741e3ea46e78 +size 552182 diff --git a/src/sounds/Classic UI SFX - Short - Low #17.wav b/src/sounds/Classic UI SFX - Short - Low #17.wav new file mode 100644 index 0000000..4a4608d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b2daee3db271eb4244dd40ea3abb5a7f036e69e637222f7a611c89a5f0a9a3e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - Low #18.wav b/src/sounds/Classic UI SFX - Short - Low #18.wav new file mode 100644 index 0000000..efbf4cc --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bda2874577cb666819141de8eba8375bddcb07bf1e813d33ad718f6828227f1 +size 587182 diff --git a/src/sounds/Classic UI SFX - Short - Low #19.wav b/src/sounds/Classic UI SFX - Short - Low #19.wav new file mode 100644 index 0000000..6efa7e3 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f577ca8f4b9d172c1e8d10553451128ea424a0cd4e8fc2ba217f1703de74eedc +size 475182 diff --git a/src/sounds/Classic UI SFX - Short - Low #2.wav b/src/sounds/Classic UI SFX - Short - Low #2.wav new file mode 100644 index 0000000..07e9d9a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36527b664e371bc952fa2ef6cd6f5479fcfd183144812091caf234be45dd8d3f +size 496182 diff --git a/src/sounds/Classic UI SFX - Short - Low #20.wav b/src/sounds/Classic UI SFX - Short - Low #20.wav new file mode 100644 index 0000000..c8d717c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:598800de963759dbe0bca69f66facbe31546c0e39f76f0fe59950ac3dd8f1c08 +size 483182 diff --git a/src/sounds/Classic UI SFX - Short - Low #21.wav b/src/sounds/Classic UI SFX - Short - Low #21.wav new file mode 100644 index 0000000..ea90758 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca62b2181585cbc11f1c66ba1e1d83b2987e50f9a5adb236c9c80dc29a43675b +size 500182 diff --git a/src/sounds/Classic UI SFX - Short - Low #22.wav b/src/sounds/Classic UI SFX - Short - Low #22.wav new file mode 100644 index 0000000..3505329 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49ad78c33edc1c0bcfdb5d9d2d6d559b7530403cec0d7ccfb770fb4fb3356527 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - Low #23.wav b/src/sounds/Classic UI SFX - Short - Low #23.wav new file mode 100644 index 0000000..5cac1d4 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3c1f824ac3a97213b56c1493b4493ef11d3292bff84eac4eeb9134b9546941c +size 564182 diff --git a/src/sounds/Classic UI SFX - Short - Low #24.wav b/src/sounds/Classic UI SFX - Short - Low #24.wav new file mode 100644 index 0000000..d3b0245 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb1948113b38e53a46ca720b3096a6b991c4dce76f9b3dbdec2268566587242b +size 501182 diff --git a/src/sounds/Classic UI SFX - Short - Low #25.wav b/src/sounds/Classic UI SFX - Short - Low #25.wav new file mode 100644 index 0000000..aa73af8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44dc28db05d46c9eba8710a3f9fade5722551f7bbbd4318f391a78cfc2995538 +size 504182 diff --git a/src/sounds/Classic UI SFX - Short - Low #3.wav b/src/sounds/Classic UI SFX - Short - Low #3.wav new file mode 100644 index 0000000..9d62309 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b78146a7c13a9dc54279b0d6909ce7d33bd8521f53d8f70d5cc35941b04be055 +size 543182 diff --git a/src/sounds/Classic UI SFX - Short - Low #4.wav b/src/sounds/Classic UI SFX - Short - Low #4.wav new file mode 100644 index 0000000..42499ed --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfcb2172fc599b189deecc57f0ceaa8fa54059442194829e5ae5ef78bb4960a5 +size 502182 diff --git a/src/sounds/Classic UI SFX - Short - Low #5.wav b/src/sounds/Classic UI SFX - Short - Low #5.wav new file mode 100644 index 0000000..6092153 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d120f6502806f3b7c1259d89aa90a0cf851f7f4826d454bd34b740f40b73f59 +size 607182 diff --git a/src/sounds/Classic UI SFX - Short - Low #6.wav b/src/sounds/Classic UI SFX - Short - Low #6.wav new file mode 100644 index 0000000..8708e29 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70ba897bce8a0ec2ce6c0c2b16dfb2fd1b5cefa3eb00c152a489de35188bbf83 +size 448182 diff --git a/src/sounds/Classic UI SFX - Short - Low #7.wav b/src/sounds/Classic UI SFX - Short - Low #7.wav new file mode 100644 index 0000000..64a7cce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d465d28fc3c3c9a1dc2992724b3a674ff4d31aa9721c222cd00434ba53f4086 +size 487182 diff --git a/src/sounds/Classic UI SFX - Short - Low #8.wav b/src/sounds/Classic UI SFX - Short - Low #8.wav new file mode 100644 index 0000000..0ed12c7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62dc712e485e785716d944f0764c0588f00dd64185e4a205393f7a7df651ccfb +size 505182 diff --git a/src/sounds/Classic UI SFX - Short - Low #9.wav b/src/sounds/Classic UI SFX - Short - Low #9.wav new file mode 100644 index 0000000..18c0b1a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2b0db41db5cddc7e8d6884c856b86f0b77e771958a1da9867455a6212407074 +size 518182 diff --git a/src/sounds/UI_Single_Set 16_01.ogg b/src/sounds/UI_Single_Set 16_01.ogg new file mode 100644 index 0000000..b6c70e0 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d04ded8bae2d120e79e7969910c7016dc00ec8b22680ab4ed5842d257616c348 +size 6983 diff --git a/src/sounds/UI_Single_Set 16_02.ogg b/src/sounds/UI_Single_Set 16_02.ogg new file mode 100644 index 0000000..1cdb93a --- /dev/null +++ b/src/sounds/UI_Single_Set 16_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dee515a756b38ea169ebfabd91320a5d50873993765095920ec2f9e142dfd00d +size 7069 diff --git a/src/sounds/UI_Single_Set 16_03.ogg b/src/sounds/UI_Single_Set 16_03.ogg new file mode 100644 index 0000000..4bdfd18 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_03.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8d972d72b3570f0638e094e84791ad0aa8a39b9d4806b8a5b441802a73bf3ac +size 7156 diff --git a/src/sounds/UI_TwoNote_Set 15_01.ogg b/src/sounds/UI_TwoNote_Set 15_01.ogg new file mode 100644 index 0000000..7d8a3c2 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad955c552cc1a111a9885fcb09f460e03f6b31ef9ecd1637a49970c0edc5fef2 +size 7624 diff --git a/src/sounds/UI_TwoNote_Set 15_02.ogg b/src/sounds/UI_TwoNote_Set 15_02.ogg new file mode 100644 index 0000000..6a5a4c9 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78e1b6c3fcff1f9f4a3a2247b8b835d26585b089ea36f21ba212d99ea6f60ba2 +size 7596 From a69147a4f73cf626b92622a8ee22b54f538d41a9 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Thu, 2 Apr 2026 14:20:30 +0300 Subject: [PATCH 02/38] feat: Implemented dolphin integration --- src/bun/api/cache.ts | 1 + .../api/games/services/launchGameService.ts | 22 ++++++++- src/bun/api/hooks/games.ts | 8 ++++ src/bun/api/jobs/emulator-download-job.ts | 29 +++++++++++ src/bun/api/jobs/update-store.ts | 2 +- .../dolphin.ts | 37 ++++++++++++++ .../package.json | 15 ++++++ .../pcsx2.ts | 8 ++++ .../ppsspp.ts | 9 ++++ src/bun/api/plugins/register-plugins.ts | 2 + .../api/store/services/emulatorsService.ts | 31 +++++++++--- src/bun/api/store/services/gamesService.ts | 9 +--- src/bun/api/store/store.ts | 2 +- src/mainview/components/Header.tsx | 48 ++++++++++--------- .../components/store/InvalidStoreError.tsx | 10 ++++ .../components/store/StoreEmulatorCard.tsx | 4 +- src/mainview/routes/game/$source.$id.tsx | 9 +--- src/mainview/routes/index.tsx | 4 +- src/mainview/routes/store/tab/emulators.tsx | 8 ++-- src/mainview/routes/store/tab/games.tsx | 4 +- src/mainview/scripts/queries/store.ts | 2 +- src/mainview/scripts/spatialNavigation.ts | 2 +- src/shared/constants.ts | 10 ++++ src/shared/types..d.ts | 3 +- 24 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json create mode 100644 src/mainview/components/store/InvalidStoreError.tsx diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index 941ba7a..6aa465a 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -20,6 +20,7 @@ export async function getOrCached (key: string, getter: () => Promise, opt } const data = await getter(); + if (data === undefined) return data; const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index add3c59..894f6db 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -10,6 +10,8 @@ import { cores } from '../../emulatorjs/emulatorjs'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { EmulatorPackageType } from '@/shared/constants'; import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; +import { getOrCached } from '../../cache'; +import { getScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; @@ -285,11 +287,27 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl => { // glob file search causes issues so do manual search - const glob = new Glob(dl.pattern); if (await fs.exists(storeEmulatorFolder)) { + const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined; + let bin: string | undefined = (dl as any).bin; + if (!bin && dl.type === 'scoop') + { + const data = await getScoopPackage(id, dl.url); + + if (data) + { + bin = data.bin; + } + } + const files = (await fs.readdir(storeEmulatorFolder)) - .filter(f => glob.match(f)); + .filter(f => + { + if (glob && glob.match(f)) return true; + if (bin && f === bin) return true; + }); + return files.map(f => path.join(storeEmulatorFolder, f)); } return []; diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index ff3ec04..824c59c 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -18,6 +18,14 @@ export class GameHooks id: number; }; }], string[] | undefined>(['ctx']); + /** + * Is the given emulator for the given command supported + * @returns The possible value is if it can support it but not right now. To show grayed out icon. + */ + emulatorLaunchSupport = new SyncBailHook<[ctx: { + emulator: string; + source?: EmulatorSourceEntryType; + }], { id: string; possible: boolean; } | undefined>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index f79db72..42d20b6 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -12,6 +12,7 @@ import { Downloader } from "@/bun/utils/downloader"; import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; +import { getScoopPackage } from "../store/services/emulatorsService"; type EmulatorDownloadStates = "download" | "extract"; @@ -55,6 +56,34 @@ export class EmulatorDownloadJob implements IJob await ensureDir(storeFolder); console.log("Updating Store"); - const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], { + const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { cwd: storeFolder, stdout: 'pipe', stderr: 'pipe', diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts new file mode 100644 index 0000000..dc0e28d --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -0,0 +1,37 @@ + +import { config, db } from "@/bun/api/app"; +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import path from 'node:path'; +import desc from './package.json'; + +export default class DOLPHINIntegration implements PluginType +{ + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'DOLPHIN') + return { id: desc.name, possible: !!ctx.source }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + { + if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir) + { + const args = ["--batch"]; + + const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]); + args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); + args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + return args; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json new file mode 100644 index 0000000..07fe38d --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.dolphin", + "displayName": "DOLPHIN Integration", + "version": "0.0.1", + "description": "DOLPHIN Emulator Integration", + "main": "./dolphin.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "keywords": [ + "integration", + "emulator", + "wiiu", + "gc", + "dolphin" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 072752b..e4c2cbd 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -11,6 +11,14 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'PCSX2') + { + return { id: desc.name, possible: ctx.source?.type === 'store' }; + } + }); + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 8384213..7fb3fd9 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -14,6 +14,15 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { + + ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + { + if (ctx.emulator === 'PPSSPP') + { + return { id: desc.name, possible: ctx.source?.type === 'store' }; + } + }); + ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 9f223b2..99b9d17 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -2,6 +2,7 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; +import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; @@ -11,6 +12,7 @@ export default async function register (pluginManager: PluginManager) const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, + { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1340731..d326fe6 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,8 +1,9 @@ -import { EmulatorPackageType } from "@/shared/constants"; +import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; import { emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; +import { getOrCached } from "../../cache"; export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) { @@ -21,16 +22,32 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT systems, gameCount, validSources: execPaths, - integration: findEmulatorPluginIntegration(emulator.name) + integration: findEmulatorPluginIntegration(emulator.name, execPaths) }; return em; } -export function findEmulatorPluginIntegration (name: string) +export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]) { - const lowerCaseName = name.toLowerCase(); - const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName)); - if (!integration) return undefined; - return { name: integration[0], version: integration[1].description.version }; + const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s); + + if (hasSupport.length <= 0) return undefined; + return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) }; +} + +export async function getScoopPackage (id: string, url: string) +{ + const data = await getOrCached(`scoop-dl-${id}`, async () => + { + const res = await fetch(url); + if (res.ok) + { + return ScoopPackageSchema.parseAsync(await res.json()); + } + console.error(res.statusText); + return undefined; + }); + + return data; } \ No newline at end of file diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index ae0181f..99aa15a 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -101,14 +101,7 @@ export async function getAllStoreEmulatorPackages () const emulators = await fs.readdir(emulatorsBucket); const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); - const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => - { - if (e.error) - { - console.error(e.error); - } - return e.data; - }).map(e => e.data!); + const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.parse(JSON.parse(d))); return emulatesParsed; } diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index d29746d..074d8bd 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -148,7 +148,7 @@ export const store = new Elysia({ prefix: '/api/store' }) sources: execPaths, biosRequirement: emulatorPackage.bios, bios: biosFiles, - integration: findEmulatorPluginIntegration(emulatorPackage.name) + integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths) }; return emulator; diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 76b87b6..cc17aba 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -70,6 +70,7 @@ export interface HeaderButton icon: JSX.Element; external?: boolean; action?: () => void; + className?: string; } export interface HeaderAccount @@ -247,25 +248,28 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { - return
    -
    - - - - - -
    - {!!data.buttons &&
    } -
    - {data.buttonElements ?? data.buttons?.map(b => {b.icon})} -
    + const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + return
    + +
    + + + + + +
    + {!!data.buttons &&
    } +
    + {data.buttonElements ?? data.buttons?.map(b => {b.icon})} +
    +
    ; } @@ -296,13 +300,13 @@ export function HeaderUI (data: HeaderUIParams) > {data.title} - , id: "settings", action: goToSettings, external: true }]} /> + , id: "header-settings-btn", action: goToSettings, external: true }]} /> ); } -export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -311,7 +315,7 @@ export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) return <>
    -
    +
    ; diff --git a/src/mainview/components/store/InvalidStoreError.tsx b/src/mainview/components/store/InvalidStoreError.tsx new file mode 100644 index 0000000..646721d --- /dev/null +++ b/src/mainview/components/store/InvalidStoreError.tsx @@ -0,0 +1,10 @@ +import { ErrorComponentProps } from "@tanstack/react-router"; +import { TriangleAlert } from "lucide-react"; + +export default function Error (data: ErrorComponentProps) +{ + return
    +
    Invalid Store. Update App.
    +
    {data.error.message}
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 202c38c..2102503 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -81,8 +81,8 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    -
    + {!!data.emulator.integration &&
    +
    } {data.emulator.validSources.slice(0, 3).map(s => { diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 7147e79..ec57e71 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -3,7 +3,7 @@ import { RPC_URL } from "@shared/constants"; import { useEffect, useRef, useState } from "react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; -import { HeaderUI } from "../../components/Header"; +import { HeaderUI, StickyHeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; import Shortcuts from "../../components/Shortcuts"; @@ -146,7 +146,6 @@ function RouteComponent () const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); - const sentinelRef = useRef(null); const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); @@ -158,7 +157,6 @@ function RouteComponent () const { shortcuts } = useShortcutContext(); - useStickyDataAttr(headerRef, sentinelRef, ref); const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ @@ -176,10 +174,7 @@ function RouteComponent () }} >
    -
    -
    - -
    +
    diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index d285464..fade167 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -315,8 +315,8 @@ export default function ConsoleHomeUI () headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( { id: "search-header-button", icon: }, - { id: "power-button", icon: , external: true, action: () => close.mutate() }, - { id: "settings-header-button", icon: , external: true, action: () => Router.navigate({ to: "/settings/accounts" }) } + { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, + { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); return ( diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 7fd1e0f..7d1aafd 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,7 +1,7 @@ -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Joystick } from 'lucide-react'; +import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router'; +import { Joystick, TriangleAlert } from 'lucide-react'; import { useContext, useEffect } from 'react'; import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard'; @@ -9,9 +9,11 @@ import { StoreContext } from '@/mainview/scripts/contexts'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, + errorComponent: InvalidStoreError }); function RouteComponent () @@ -22,7 +24,7 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery(storeEmulatorsQuery); + const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true }); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7bbf93e..7aee585 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -8,9 +8,11 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import { StoreContext } from '@/mainview/scripts/contexts'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; export const Route = createFileRoute('/store/tab/games')({ - component: RouteComponent + component: RouteComponent, + errorComponent: InvalidStoreError }); function RouteComponent () diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index e7b84f1..bdd6337 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -6,7 +6,7 @@ export const storeEmulatorsQuery = queryOptions({ queryKey: ['store-emulators'], queryFn: async () => { const { data, error } = await storeApi.api.store.emulators.get(); - if (error) throw error; + if (error) throw new Error(JSON.stringify(error.value)); return data; } }); diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 3129f2a..51d212a 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -107,7 +107,7 @@ SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => node: GetFocusedElement(newFocusKey) }; setCurrentFocusedKey(newFocusKey, focusDetails); - window.dispatchEvent(new CustomEvent('focuschanged', { + (GetFocusedElement(newFocusKey) ?? window).dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: details })); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index acced4c..33531f7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -108,12 +108,22 @@ export const EmulatorPackageSchema = z.object({ z.object({ type: z.literal('direct'), url: z.url(), + }), + z.object({ + type: z.literal('scoop'), + url: z.url(), }) ]))).optional(), systems: z.array(z.string()), bios: z.literal(["required", "optional"]).optional() }); +export const ScoopPackageSchema = z.object({ + version: z.string(), + url: z.url().optional(), + architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional() +}); + export const SystemInfoSchema = z.object({ battery: z.object({ percent: z.number(), diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 760fc7f..7bc8ba7 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -18,7 +18,8 @@ declare interface FrontEndEmulator validSources: EmulatorSourceEntryType[]; integration?: { name: string; - version: string; + version?: string; + possible: boolean; }; } From 34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 3 Apr 2026 23:02:22 +0300 Subject: [PATCH 03/38] feat: Implemented emulator versions and updating --- src/bun/api/games/games.ts | 6 +- .../api/games/services/launchGameService.ts | 4 +- src/bun/api/hooks/emulators.ts | 14 +- src/bun/api/hooks/games.ts | 8 +- src/bun/api/jobs/emulator-download-job.ts | 79 +++-------- src/bun/api/jobs/update-store.ts | 26 +++- .../dolphin.ts | 7 +- .../pcsx2.ts | 62 +++++---- .../ppsspp.ts | 88 +++++++----- src/bun/api/settings/services.ts | 7 +- .../api/store/services/emulatorsService.ts | 129 ++++++++++++++++-- src/bun/api/store/store.ts | 46 ++++--- src/mainview/components/FocusTooltip.tsx | 7 +- src/mainview/components/StatList.tsx | 10 +- src/mainview/components/game/ActionButton.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 2 +- src/mainview/components/options/Button.tsx | 4 +- .../components/store/StoreEmulatorCard.tsx | 31 +++-- .../routes/store/details.emulator.$id.tsx | 61 +++++++-- src/mainview/scripts/queries/store.ts | 12 +- src/shared/constants.ts | 22 ++- src/shared/types..d.ts | 19 ++- 22 files changed, 434 insertions(+), 212 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 9666d44..73b9e1f 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -303,7 +303,8 @@ export default new Elysia() validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], - gameCount: 0 + gameCount: 0, + integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } else @@ -313,7 +314,8 @@ export default new Elysia() logo: "", systems: [], gameCount: 0, - validSources: [] + validSources: [], + integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 894f6db..a91e6ca 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -11,7 +11,7 @@ import { LaunchGameJob } from '../../jobs/launch-game-job'; import { EmulatorPackageType } from '@/shared/constants'; import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; import { getOrCached } from '../../cache'; -import { getScoopPackage } from '../../store/services/emulatorsService'; +import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; @@ -293,7 +293,7 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath let bin: string | undefined = (dl as any).bin; if (!bin && dl.type === 'scoop') { - const data = await getScoopPackage(id, dl.url); + const data = await getOrCachedScoopPackage(id, dl.url); if (data) { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index f48ea9f..ed2d742 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,4 +1,5 @@ -import { AsyncSeriesBailHook } from "tapable"; +import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export class EmulatorHooks { @@ -7,4 +8,15 @@ export class EmulatorHooks systems: EmulatorSystem[]; biosFolder: string; }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); + + /** + * Triggered when emulator is downloaded or updated + */ + emulatorPostInstall = new AsyncSeriesHook<[ctx: { + emulator: string; + emulatorPackage?: EmulatorPackageType; + path: string; + update: boolean; + info: EmulatorDownloadInfoType; + }]>(['ctx']); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index 824c59c..ea50476 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -1,5 +1,5 @@ import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; export class GameHooks { @@ -13,6 +13,7 @@ export class GameHooks */ emulatorLaunch = new AsyncSeriesBailHook<[ctx: { autoValidCommand: CommandEntry; + dryRun: boolean, game: { source: string; id: number; @@ -20,12 +21,13 @@ export class GameHooks }], string[] | undefined>(['ctx']); /** * Is the given emulator for the given command supported - * @returns The possible value is if it can support it but not right now. To show grayed out icon. + * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. + * */ emulatorLaunchSupport = new SyncBailHook<[ctx: { emulator: string; source?: EmulatorSourceEntryType; - }], { id: string; possible: boolean; } | undefined>(['ctx']); + }], EmulatorSupport | undefined>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 42d20b6..74e13d2 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -2,17 +2,15 @@ import { EmulatorPackageType } from "@/shared/constants"; import { getStoreEmulatorPackage } from "../store/services/gamesService"; import { IJob, JobContext } from "../task-queue"; import z from "zod"; -import { Glob } from "bun"; -import { config } from "../app"; +import { config, plugins } from "../app"; import path from 'node:path'; -import { getOrCachedGithubRelease } from "../cache"; import Seven from 'node-7z'; import fs from "node:fs/promises"; import { Downloader } from "@/bun/utils/downloader"; import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; -import { getScoopPackage } from "../store/services/emulatorsService"; +import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; type EmulatorDownloadStates = "download" | "extract"; @@ -23,73 +21,24 @@ export class EmulatorDownloadJob implements IJob, EmulatorDownloadStates>) { this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); if (!this.emulatorPackage) throw new Error("Emulator not found"); - if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads"); + const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource); - const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`]; - if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`); - - const validDownload = validDownloads.find(d => d.type === this.downloadSource); - if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`); - - let downloadUrl: URL; - if (validDownload.type === 'github') - { - console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`); - const latestRelease = await getOrCachedGithubRelease(validDownload.path); - const glob = new Glob(validDownload.pattern); - const validAsset = latestRelease.assets.find(a => glob.match(a.name)); - if (!validAsset) throw new Error("Could Not Find Valid Asset"); - downloadUrl = new URL(validAsset.browser_download_url); - } else if (validDownload.type === 'direct') - { - downloadUrl = new URL(validDownload.url); - } else if (validDownload.type === 'scoop') - { - const data = await getScoopPackage(this.emulator, validDownload.url); - let scoopDownload: URL | undefined; - if (data) - { - if (data.url) - { - scoopDownload = new URL(data.url); - } else if (data.architecture) - { - if (process.arch === 'x64' && data.architecture["64bit"]) - { - scoopDownload = new URL(data.architecture["64bit"].url); - } else if (process.arch === "arm64" && data.architecture["arm64"]) - { - scoopDownload = new URL(data.architecture["arm64"].url); - } - } - } - - if (scoopDownload) - { - downloadUrl = scoopDownload; - } else - { - throw new Error("Could not find scoop download"); - } - } else - { - throw new Error("Download Type Unsupported"); - } - - const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); + const emulatorsFolder = getEmulatorPath(this.emulator); if (this.dryRun) { @@ -99,7 +48,7 @@ export class EmulatorDownloadJob implements IJob const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); - console.log("Updating Store"); - const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { + console.log("Adding Store Package"); + let proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { cwd: storeFolder, stdout: 'pipe', stderr: 'pipe', @@ -40,9 +40,27 @@ export default class UpdateStoreJob implements IJob } }); - const stdout = await new Response(proc.stdout).text(); + let stdout = await new Response(proc.stdout).text(); console.log(stdout); - const stderr = await new Response(proc.stderr).text(); + let stderr = await new Response(proc.stderr).text(); + if (stderr) + console.error(stderr); + await proc.exited; + + console.log("Updating Store Package"); + proc = Bun.spawn([process.execPath, "update", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], { + cwd: storeFolder, + stdout: 'pipe', + stderr: 'pipe', + env: { + BUN_BE_BUN: "1", + BUN_INSTALL_CACHE_DIR: tempCache + } + }); + + stdout = await new Response(proc.stdout).text(); + console.log(stdout); + stderr = await new Response(proc.stderr).text(); if (stderr) console.error(stderr); await proc.exited; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index dc0e28d..af12a7d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -11,7 +11,12 @@ export default class DOLPHINIntegration implements PluginType ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => { if (ctx.emulator === 'DOLPHIN') - return { id: desc.name, possible: !!ctx.source }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index e4c2cbd..c2de7e3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -15,13 +15,26 @@ export default class PCSX2Integration implements PluginType { if (ctx.emulator === 'PCSX2') { - return { id: desc.name, possible: ctx.source?.type === 'store' }; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } } }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir) { const args = ["-batch"]; if (config.get('launchInFullscreen')) @@ -30,32 +43,35 @@ export default class PCSX2Integration implements PluginType } args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); - const configFileContents = await Bun.file(configFile).text(); + if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) + { + const configFileContents = await Bun.file(configFile).text(); - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); - const view = { - BIOS_PATH: biosFolder, - SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), - SAVE_STATES_PATH: path.join(savesFolder, 'states'), - MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), - CACHE_PATH: path.join(storageFolder, 'cache'), - COVERS_PATH: path.join(storageFolder, 'covers'), - TEXTURES_PATH: path.join(storageFolder, 'textures'), - RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), - }; + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; - await Promise.all(Object.values(view).map(p => ensureDir(p))); + await Promise.all(Object.values(view).map(p => ensureDir(p))); - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + } return args; } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 7fb3fd9..fddf25c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -14,18 +14,31 @@ export default class PCSX2Integration implements PluginType { load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => { if (ctx.emulator === 'PPSSPP') { - return { id: desc.name, possible: ctx.source?.type === 'store' }; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } + } }); ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir) { const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; if (config.get('launchInFullscreen')) @@ -33,44 +46,47 @@ export default class PCSX2Integration implements PluginType args.push("--fullscreen"); } - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; - - switch (process.platform) + if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) { - case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; - break; - case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; - break; - } + let confPath: string | undefined = undefined; + let controlsPath: string | undefined = undefined; - let ppssppPath = ''; - if (process.platform === 'win32') - { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); - } else - { - //TODO: Use way to set custom memstick path when they support it - ensureDir(path.join(homedir(), '.config', 'ppsspp')); - ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); - } + switch (process.platform) + { + case "win32": + confPath = configFilePathWin32; + controlsPath = configControlsFilePathWin32; + break; + case 'linux': + confPath = configFilePathLinux; + controlsPath = configControlsFilePathLinux; + break; + } - ensureDir(ppssppPath); + let ppssppPath = ''; + if (process.platform === 'win32') + { + ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + } else + { + //TODO: Use way to set custom memstick path when they support it + ensureDir(path.join(homedir(), '.config', 'ppsspp')); + ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); + } - if (confPath) - { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); - } + ensureDir(ppssppPath); - if (controlsPath) - { - const controlsFileContents = await Bun.file(controlsPath).text(); - await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + if (confPath) + { + const configFileContents = await Bun.file(confPath).text(); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + } + + if (controlsPath) + { + const controlsFileContents = await Bun.file(controlsPath).text(); + await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + } } return args; diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index afaa5fe..7b7a89e 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -7,6 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; +import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; /** * Get emulators based on local games. Only the ones we probably need. @@ -73,7 +74,8 @@ export async function getRelevantEmulators () systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), gameCount: 0, isCritical: false, - validSources: execPaths + validSources: execPaths, + integrations: findEmulatorPluginIntegration(emulator, execPaths) }; return em; @@ -86,7 +88,8 @@ export async function getRelevantEmulators () systems: [], gameCount: 0, isCritical: false, - description: "Embedded Emulator. Uses Retroarch Cores" + description: "Embedded Emulator. Uses Retroarch Cores", + integrations: [] }); return finalEmulators.map(e => diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index d326fe6..1682ecb 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,9 +1,11 @@ -import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; -import { emulatorsDb, plugins } from "../../app"; +import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; +import { config, emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; -import { getOrCached } from "../../cache"; +import { getOrCached, getOrCachedGithubRelease } from "../../cache"; +import path from "node:path"; +import fs from "node:fs/promises"; export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) { @@ -22,21 +24,130 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT systems, gameCount, validSources: execPaths, - integration: findEmulatorPluginIntegration(emulator.name, execPaths) + integrations: findEmulatorPluginIntegration(emulator.name, execPaths) }; return em; } -export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]) +export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { - const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s); + const hasSupport = validSources.concat(undefined).map(s => + { + const support = plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s }); + if (support) + { + return { ...support, source: s }; + } - if (hasSupport.length <= 0) return undefined; - return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) }; + return undefined; + }).filter(s => !!s); + + if (hasSupport.length <= 0) return []; + return hasSupport; } -export async function getScoopPackage (id: string, url: string) +export function getEmulatorPath (emulator: string) +{ + return path.join(config.get('downloadPath'), "emulators", emulator); +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} + +export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) +{ + if (!emulator.downloads) throw new Error("Emulator has no downloads"); + + const validDownloads = emulator.downloads[`${process.platform}:${process.arch}`]; + if (!validDownloads) throw new Error(`Now downloads in ${emulator.name} for platform ${process.platform}:${process.arch}`); + + const validDownload = validDownloads.find(d => d.type === source); + if (!validDownload) throw new Error(`Download type ${source} not found`); + + let downloadUrl: URL; + let versionInfo: EmulatorDownloadInfoType = { + id: "", + downloadDate: new Date(), + type: validDownload.type + }; + if (validDownload.type === 'github') + { + const latestRelease = await getOrCachedGithubRelease(validDownload.path); + const glob = new Bun.Glob(validDownload.pattern); + const validAsset = latestRelease.assets.find(a => glob.match(a.name)); + if (!validAsset) throw new Error("Could Not Find Valid Asset"); + downloadUrl = new URL(validAsset.browser_download_url); + versionInfo.version = latestRelease.tag_name; + versionInfo.url = latestRelease.url; + versionInfo.id = String(latestRelease.id); + versionInfo.description = latestRelease.body; + + } else if (validDownload.type === 'direct') + { + downloadUrl = new URL(validDownload.url); + versionInfo.id = validDownload.url; + versionInfo.url = validDownload.url; + } else if (validDownload.type === 'scoop') + { + const data = await getOrCachedScoopPackage(emulator.name, validDownload.url); + let scoopDownload: URL | undefined; + if (data) + { + if (data.url) + { + scoopDownload = new URL(data.url); + } else if (data.architecture) + { + if (process.arch === 'x64' && data.architecture["64bit"]) + { + scoopDownload = new URL(data.architecture["64bit"].url); + } else if (process.arch === "arm64" && data.architecture["arm64"]) + { + scoopDownload = new URL(data.architecture["arm64"].url); + } + } + } + + if (scoopDownload) + { + downloadUrl = scoopDownload; + versionInfo.version = data?.version; + versionInfo.url = data?.url; + versionInfo.description = data?.description; + } else + { + throw new Error("Could not find scoop download"); + } + } else + { + throw new Error("Download Type Unsupported"); + } + + return { url: downloadUrl, info: versionInfo }; +} + +export async function getOrCachedScoopPackage (id: string, url: string) { const data = await getOrCached(`scoop-dl-${id}`, async () => { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 074d8bd..2736fe2 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,17 +3,16 @@ import Elysia, { status } from "elysia"; import { config, db, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { StoreGameSchema } from "@/shared/constants"; +import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants"; import { findExecsByName } from "../games/services/launchGameService"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; -import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache"; +import { CACHE_KEYS, getOrCached } from "../cache"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { Glob } from "bun"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService"; +import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; export const store = new Elysia({ prefix: '/api/store' }) @@ -107,6 +106,15 @@ export const store = new Elysia({ prefix: '/api/store' }) return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) + .get('/emulator/:id/update', async ({ params: { id } }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + return downloadInfo; + }, + { + response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) + }) .get('/emulator/:id', async ({ params: { id } }) => { const emulatorPackage = await getStoreEmulatorPackage(id); @@ -120,6 +128,7 @@ export const store = new Elysia({ prefix: '/api/store' }) const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); const emulator: FrontEndEmulatorDetailed = { name: emulatorPackage.name, @@ -129,38 +138,31 @@ export const store = new Elysia({ prefix: '/api/store' }) screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), gameCount: 0, homepage: emulatorPackage.homepage, - downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => { - if (d.type === 'github' && d.path) - { - const release = await getOrCachedGithubRelease(d.path); - const glob = new Glob(d.pattern); - const download: FrontEndEmulatorDetailedDownload = { - name: d.type, - type: release.assets.find(a => glob.match(a.name))?.content_type - }; - return download; - }; - - return { name: d.type, type: "Unknown" }; - }) ?? []), + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), logo: emulatorPackage.logo, - sources: execPaths, biosRequirement: emulatorPackage.bios, bios: biosFiles, - integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths) + integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), + storeDownloadInfo: storeDownloadInfo, + hasUpdate: storeDownloadInfo?.hasUpdate ?? null }; return emulator; }, { params: z.object({ id: z.string() }) }) - .post('/install/emulator/:id/:source', async ({ params: { source, id } }) => + .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) { return status("Conflict", "Installation already running"); } - const job = new EmulatorDownloadJob(id, source); + const job = new EmulatorDownloadJob(id, source, { isUpdate }); return taskQueue.enqueue(EmulatorDownloadJob.id, job); + }, { + body: z.object({ isUpdate: z.boolean().optional() }) }) .delete('/emulator/:id', async ({ params: { id } }) => { diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 5a165b1..6916c1e 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -12,7 +12,7 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible { const dataTooltip = e.getAttribute('data-tooltip'); setHoverText(dataTooltip ?? undefined); - setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); + setHoverTextType(e.getAttribute('data-tooltip-type') ?? 'accent'); }; const { isPointer } = useActiveControl(); @@ -29,7 +29,10 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible const tooltipStyles = { base: 'bg-base-100 text-base-content', accent: 'bg-accent text-accent-content', - error: 'bg-error text-error-content' + error: 'bg-error text-error-content', + warning: 'bg-warning text-warning-content', + info: 'bg-info text-info-content', + success: 'bg-success text-success-content' }; return !!hoverText && (data.visible ?? true) && !isPointer &&

    {hoverText}

    ; diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index 3ff22f9..de1c231 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -29,7 +29,7 @@ export default function StatList (data: { return
      - {data.stats.map((s, i) => + {data.stats.flatMap((s, i) => { let content: any = undefined; if (s.content instanceof Array) @@ -37,13 +37,9 @@ export default function StatList (data: { content =
      {s.content.map((c, ci) => {c})}
      ; } else { - content =
      {s.icon}{s.content}
      ; + content =
      {s.icon}{s.content}
      ; } - const element = <> -
    ; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index c0f3b78..3e5ebed 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -31,7 +31,7 @@ export default function ActionButton (data: { ref={ref} onClick={data.onAction} data-tooltip={data.tooltip} - data-tooltip_type={data.tooltip_type} + data-tooltip-type={data.tooltip_type} className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index e89f910..2ee89c6 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -137,7 +137,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index faef3fa..c9ba6a3 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -33,7 +33,7 @@ export function Button (data: { focusClassName?: string; cssStyle?: CSSProperties; tooltip?: string; - tooltipType?: "base" | "accent" | "error"; + tooltipType?: "base" | "accent" | "error" | "warning"; } & InteractParams & FocusParams) { const handleAction = (e?: any) => @@ -58,7 +58,7 @@ export function Button (data: { onClick={handleAction} disabled={data.disabled} data-tooltip={data.tooltip} - data-tooltip_type={data.tooltipType} + data-tooltip-type={data.tooltipType} style={data.cssStyle} className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4", styles[data.style ?? 'base'], diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 2102503..a9f7720 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -5,11 +5,13 @@ import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; +import { BadgeCheck, ChevronRight, CircleFadingArrowUp, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import { useQuery } from "@tanstack/react-query"; +import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; export const emulatorStatusIcons: Record = { store: , @@ -42,8 +44,9 @@ export function StoreEmulatorCard (data: { } }); + const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); + useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); - const { isMouse, isTouch } = useActiveControl(); return (
    s.exists)} - onClick={isTouch ? handleSelect : undefined} - className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} + onClick={handleSelect} + className={twMerge("relative focusable focusable-info focusable-hover bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none cursor-pointer", data.className)} >
    @@ -81,21 +84,27 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration &&
    -
    + {updateInfo?.hasUpdate &&
    +
    + +
    +
    } + {data.emulator.integrations.length > 0 &&
    i.supportLevel)} + data-full-support={data.emulator.integrations.some(i => i.supportLevel === 'full')} + className="tooltip not-aria-disabled:tooltip-primary" + data-tip={data.emulator.integrations.some(i => i.supportLevel) ? data.emulator.integrations.some(i => i.supportLevel === 'full') ? "Full Support" : "Partial SUpport" : "Can Integrate"} + > +
    } {data.emulator.validSources.slice(0, 3).map(s => { return
    -
    +
    {emulatorStatusIcons[s.type]}
    ; })} - {isMouse && <> - - } -
    diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 9ad4678..a40e2b0 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; +import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -59,6 +59,7 @@ function HomePageLink (data: { homepage?: string; }) function TitleArea (data: { emulator?: FrontEndEmulatorDetailed; onInstall: (source: string) => void; + onUpdate: (source: string) => void; }) { const queryClient = useQueryClient(); @@ -70,6 +71,7 @@ function TitleArea (data: { }, }); const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? '')); + const updateToVersion = data.emulator?.downloads.find(d => d.version === data.emulator!.storeDownloadInfo?.type)?.version ?? data.emulator?.downloads[0]?.version; const deleteBios = useMutation({ ...deleteBiosMutation, onSuccess (data, variables, onMutateResult, context) @@ -122,7 +124,7 @@ function TitleArea (data: { const isInstalling = !!installJob || !!biosInstallJob; const options: DialogEntry[] = []; - const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); + const installedFromStore = !!data.emulator?.validSources.find(s => s.type === 'store' && s.exists); if (data.emulator) { if (!isInstalling && !installedFromStore) @@ -155,6 +157,22 @@ function TitleArea (data: { id: "delete" }); + if ((!data.emulator.storeDownloadInfo || data.emulator.storeDownloadInfo.hasUpdate)) + { + options.push({ + content: `Update ${data.emulator.storeDownloadInfo?.type}: ${data.emulator.storeDownloadInfo?.version ?? "Unknown"} > ${updateToVersion}`, + type: 'warning', + icon: , + action (ctx) + { + const source = data.emulator?.storeDownloadInfo?.type ?? data.emulator?.downloads[0]?.type; + if (source) data.onUpdate(source); + ctx.close(); + }, + id: 'update' + }); + } + if (!data.emulator.bios || data.emulator.bios.length <= 0) { options.push({ @@ -183,7 +201,6 @@ function TitleArea (data: { id: "download-bios" }); } - } } @@ -253,13 +270,16 @@ function TitleArea (data: { {!!data.emulator?.bios?.[0] &&
    } - {data.emulator && !!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    + {data.emulator && data.emulator.integrations.length > 0 &&
    }
    + {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
    + +
    } {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
    } @@ -310,7 +330,8 @@ export function RouteComponent () }], [router]); const installMutation = useMutation({ - ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), + ...installEmulatorMutation(id), + onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); const { shortcuts } = useShortcutContext(); @@ -320,21 +341,33 @@ export function RouteComponent () { if (emulator.keywords) stats.push({ label: "Tags", content: emulator.keywords }); + if (emulator.storeDownloadInfo) + stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.sources.flatMap(s => [{ - label: "Source", content:
    -
    {emulatorStatusIcons[s.type]}{s.type}:
    -
    {s.binPath}
    + stats.push(...emulator.validSources.flatMap(s => [{ + label: "Source", content:
    +
    +
    {emulatorStatusIcons[s.type]}{s.type}
    +
    {s.binPath}
    +
    + {emulator.integrations.some(i => i.source?.type === s.type) &&
    } + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
    +
    + +
    {i.id}
    +
    +
    {`${i.capabilities?.join(", ")}`}
    +
    ; + })}
    }])); if (emulator.bios) stats.push({ label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    }); - if (emulator.integration) - { - stats.push({ label: "Integration", icon: , content: `${emulator.integration.name} (${emulator.integration.version})` }); - } + } return ( @@ -344,7 +377,7 @@ export function RouteComponent ()
    - + installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
    diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index bdd6337..4a881cd 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -64,9 +64,9 @@ export const storeGetStatsQuery = queryOptions({ }); export const installEmulatorMutation = (id: string) => mutationOptions({ mutationKey: ['install', 'emulator', id], - mutationFn: async (source: string) => + mutationFn: async (ctx: { source: string, isUpdate: boolean; }) => { - const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post(); + const { data, error } = await storeApi.api.store.install.emulator({ id })({ source: ctx.source }).post({ isUpdate: ctx.isUpdate }); if (error) throw error; return data; } @@ -85,4 +85,12 @@ export const deleteBiosMutation = mutationOptions({ const { error } = await storeApi.api.store.bios({ id }).delete(); if (error) throw error; } +}); +export const getUpdateInfoForEmulator = (id: string) => queryOptions({ + queryKey: ['emulator', 'update'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulator({ id }).update.get(); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 33531f7..3eff7d5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -121,7 +121,13 @@ export const EmulatorPackageSchema = z.object({ export const ScoopPackageSchema = z.object({ version: z.string(), url: z.url().optional(), - architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional() + description: z.string(), + bin: z.string().optional(), + architecture: z.record(z.string(), z.object({ + url: z.url(), + hash: z.string().optional(), + extract_dir: z.string().optional() + })).optional() }); export const SystemInfoSchema = z.object({ @@ -137,6 +143,10 @@ export const SystemInfoSchema = z.object({ }); export const GithubReleaseSchema = z.object({ + id: z.number(), + tag_name: z.string().optional(), + url: z.url(), + body: z.string(), assets: z.array(z.object({ name: z.string(), browser_download_url: z.url(), @@ -144,9 +154,19 @@ export const GithubReleaseSchema = z.object({ })) }); +export const EmulatorDownloadInfoSchema = z.object({ + id: z.string(), + version: z.string().optional(), + url: z.url().optional(), + description: z.string().optional(), + downloadDate: z.coerce.date(), + type: z.string() +}); + export type EmulatorPackageType = z.infer; export type StoreGameType = z.infer; export type SettingsType = z.infer; export type LocalSettingsType = z.infer; export const PlatformSchema = z.object({ slug: z.string() }); export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 7bc8ba7..85812b2 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -16,11 +16,7 @@ declare interface FrontEndEmulator description?: string; gameCount: number; validSources: EmulatorSourceEntryType[]; - integration?: { - name: string; - version?: string; - possible: boolean; - }; + integrations: EmulatorSupport[]; } declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } @@ -29,6 +25,7 @@ declare interface FrontEndEmulatorDetailedDownload { name: string; type: string | undefined; + version?: string; } declare interface FrontEndEmulatorDetailed extends FrontEndEmulator @@ -38,9 +35,9 @@ declare interface FrontEndEmulatorDetailed extends FrontEndEmulator downloads: FrontEndEmulatorDetailedDownload[]; keywords?: string[]; screenshots: string[]; - sources: EmulatorSourceEntryType[]; biosRequirement?: "required" | "optional"; bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; }; } declare interface FrontEndGameTypeDetailedAchievement @@ -265,4 +262,14 @@ declare interface FrontEndCollection description: string; path_platform_cover: string | null; game_count: number; +} + +declare type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +declare interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; } \ No newline at end of file From 04d5856f7d71c944c82877d2a1457facea4b6d31 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 3 Apr 2026 23:18:29 +0300 Subject: [PATCH 04/38] fix: Fixed emulator details buttons not showing --- src/mainview/routes/store/details.emulator.$id.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index a40e2b0..02d5c22 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -246,7 +246,7 @@ function TitleArea (data: { const handleOptionsOpen = () => { - if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false; + if (isInstalling || !data.emulator) return false; setOpen(true, 'install-btn'); }; From 09b8b9c6f850cea3b897308925faf9be02cefa1a Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sat, 4 Apr 2026 03:13:09 +0300 Subject: [PATCH 05/38] feat: Implemented emulator launching Fixes #1 --- src/bun/api/games/games.ts | 2 +- .../api/games/services/launchGameService.ts | 10 +- src/bun/api/games/services/statusService.ts | 27 +++- src/bun/api/hooks/emulators.ts | 37 ++++- src/bun/api/hooks/games.ts | 43 +++++- src/bun/api/jobs/jobs.ts | 6 +- src/bun/api/jobs/launch-game-job.ts | 73 +++++---- .../dolphin.ts | 49 +++--- .../pcsx2.ts | 113 +++++++------- .../ppsspp.ts | 139 +++++++++--------- src/bun/api/store/store.ts | 3 +- src/bun/types/typesc.schema.ts | 4 +- src/mainview/routes/game/$source.$id.tsx | 3 +- src/mainview/routes/launcher.$source.$id.tsx | 42 +++--- src/mainview/routes/settings/route.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 14 +- src/mainview/scripts/utils.ts | 8 +- src/shared/types..d.ts | 2 +- src/tests/downloads.test.ts | 2 +- src/tests/preload.ts | 3 +- 20 files changed, 351 insertions(+), 231 deletions(-) diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 73b9e1f..7c91954 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -389,7 +389,7 @@ export default new Elysia() if (validCommand) { // launch command waits for the game to exit, we don't want that. - await launchCommand(validCommand, source, id, validCommands.gameId); + await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId); return { type: 'application', command: null }; } else { diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index a91e6ca..362ff41 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -5,22 +5,20 @@ import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; import { eq } from 'drizzle-orm'; import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os, { platform } from 'node:os'; +import os from 'node:os'; import { cores } from '../../emulatorjs/emulatorjs'; import { LaunchGameJob } from '../../jobs/launch-game-job'; -import { EmulatorPackageType } from '@/shared/constants'; -import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; -import { getOrCached } from '../../cache'; +import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; export const varRegex = /%([^%]+)%/g; export const assignRegex = /(%\w+%)=(\S+) /g; -export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number) +export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) { - throw new Error(`${id} currently running`); + throw new Error(`Game currently running`); } taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index af9e62a..4f1d99b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,6 +1,6 @@ import { RPC_URL, } from "@shared/constants"; import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { getValidLaunchCommands } from "./launchGameService"; +import { findExecs, getValidLaunchCommands } from "./launchGameService"; import * as emulatorSchema from '@schema/emulators'; import { and, eq } from "drizzle-orm"; import { getErrorMessage, hashFile } from "@/bun/utils"; @@ -26,7 +26,7 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ - columns: { id: true, path_fs: true }, + columns: { id: true, path_fs: true, source: true, source_id: true }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,8 +36,27 @@ export async function getLocalGame (source: string, id: string) return localGame; } -export async function getValidLaunchCommandsForGame (source: string, id: string) +export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) }); + const allExecs = await findExecs(id, esEmulator); + return { + commands: allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)), + gameId: { source: "emulator", id: id } + }; + } const localGame = await getLocalGame(source, id); if (localGame) { @@ -70,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) const validCommand = commands.find(c => c.valid); if (validCommand) { - return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id }; + return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: String(localGame.source_id) ?? id }; } else { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index ed2d742..b968197 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,5 +1,15 @@ import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; +import { any } from "zod"; + +interface EmulatorPostInstallContext +{ + emulator: string; + emulatorPackage?: EmulatorPackageType; + path: string; + update: boolean; + info: EmulatorDownloadInfoType; +} export class EmulatorHooks { @@ -12,11 +22,24 @@ export class EmulatorHooks /** * Triggered when emulator is downloaded or updated */ - emulatorPostInstall = new AsyncSeriesHook<[ctx: { - emulator: string; - emulatorPackage?: EmulatorPackageType; - path: string; - update: boolean; - info: EmulatorDownloadInfoType; - }]>(['ctx']); + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + + constructor() + { + this.emulatorPostInstall.intercept({ + register (tap) + { + return { + ...tap, + fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) => + { + if (ctx.emulator === tap.emulator) + { + tap.fn(ctx, ...rest); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index ea50476..dd893d1 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -15,10 +15,11 @@ export class GameHooks autoValidCommand: CommandEntry; dryRun: boolean, game: { - source: string; - id: number; + source?: string; + sourceId?: string; + id: FrontEndId; }; - }], string[] | undefined>(['ctx']); + }], string[] | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -27,7 +28,7 @@ export class GameHooks emulatorLaunchSupport = new SyncBailHook<[ctx: { emulator: string; source?: EmulatorSourceEntryType; - }], EmulatorSupport | undefined>(['ctx']); + }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); /** * Fetches and returns a list of games converted to frontend. * @param ctx.localGameIds This is local game ids in the format '@' @@ -71,4 +72,38 @@ export class GameHooks updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); + + constructor() + { + this.emulatorLaunchSupport.intercept({ + register (tap) + { + return { + ...tap, + fn: (e: any, ...rest: any[]) => + { + if (e.emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + this.emulatorLaunch.intercept({ + register (tap) + { + return { + ...tap, + fn: async (e: any, ...rest: any[]) => + { + if ((e.autoValidCommand as CommandEntry).emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + } } \ No newline at end of file diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 8f836a5..7bc92c7 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -32,6 +32,7 @@ function registerJob< data: _job.dataSchema }), z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), + z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('error'), error: z.string() }) ]), open (ws) @@ -41,6 +42,9 @@ function registerJob< if (job) { ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); + } else + { + ws.send({ type: 'waiting' }); } (ws.data as any).cleanup = [ @@ -97,10 +101,10 @@ function registerJob< } export const jobs = new Elysia({ prefix: '/api/jobs' }) + .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) .use(registerJob(UpdateStoreJob)) - .use(registerJob(LaunchGameJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 91004bb..18b4fd1 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -4,40 +4,51 @@ import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq, sql } from "drizzle-orm"; -import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; export class LaunchGameJob implements IJob, "playing"> { static id = "launch-game" as const; - static dataSchema = z.optional(ActiveGameSchema); + static dataSchema = z.nullable(ActiveGameSchema); group = "launch-game"; - activeGame?: ActiveGameType; - gameId: number; + activeGame: ActiveGameType | null; + gameId: FrontEndId; validCommand: CommandEntry; - gameSource: string; - gameSourceId: string; + gameSource?: string; + gameSourceId?: string; - constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) + constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { this.gameId = gameId; this.validCommand = validCommand; this.gameSource = source; this.gameSourceId = sourceId; + this.activeGame = null; } async start (context: JobContext, "playing">, z.infer, "playing">) { - const localGame = await db.query.games.findFirst({ - where: eq(appSchema.games.id, this.gameId), columns: { - name: true, - source_id: true, - source: true - } - }); + let gameInfo: { name?: string, source_id?: string, source?: string; }; + if (this.gameId.source === 'emulator') + { + gameInfo = { name: this.gameId.id }; + } else + { + const localGame = await db.query.games.findFirst({ + where: eq(appSchema.games.id, Number(this.gameId.id)), columns: { + name: true, + source_id: true, + source: true + } + }); + if (localGame) + gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined }; + } const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ autoValidCommand: this.validCommand, - game: { source: this.gameSource, id: this.gameId } + game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }, + dryRun: false }); await new Promise((resolve, reject) => @@ -70,10 +81,15 @@ export class LaunchGameJob implements IJob + context.abortSignal.addEventListener('abort', reject); + + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => { console.error(e); reject(e); @@ -87,28 +103,27 @@ export class LaunchGameJob implements IJob + const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) => { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId)); - await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => + if (this.gameId.source === 'local') + { + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id))); + } + + await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v => { if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); }); }; - if (this.gameSource !== 'local') - { - updatePlayed(this.gameSource, this.gameSourceId); - } - else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id) - { - updatePlayed(localGame.source, localGame.source_id); - } + updatePlayed(this.gameId, this.gameSource, this.gameSourceId); }); /* Old spawn lanching, cases issues, needs to be ran as shell diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index af12a7d..59cc505 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -6,37 +6,48 @@ import desc from './package.json'; export default class DOLPHINIntegration implements PluginType { + emulator = 'DOLPHIN'; + + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'DOLPHIN') - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; }); - ctx.hooks.emulators.emulatorPostInstall.tapPromise(desc.name, async (ctx) => + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { await Bun.write(path.join(ctx.path, "portable.txt"), ""); }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + + const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); + args.push(`--user=${storageFolder}`); + + args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); + args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); + args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); + args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); + + if (ctx.autoValidCommand.metadata.romPath) { - const args = ["--batch"]; - - const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); - - args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]); - args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); - args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); - args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); - args.push(`--config=Dolphin.Interface.ConfirmStop=False`); - args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); - args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); - - return args; + args.push("--batch"); + args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index c2de7e3..2e944d2 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -9,72 +9,73 @@ import desc from './package.json'; export default class PCSX2Integration implements PluginType { + emulator = "PCSX2"; + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'PCSX2') - { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; } }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) { - const args = ["-batch"]; - if (config.get('launchInFullscreen')) - { - args.push("-fullscreen"); - } - args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - const configFileContents = await Bun.file(configFile).text(); - - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); - - const view = { - BIOS_PATH: biosFolder, - SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), - SAVE_STATES_PATH: path.join(savesFolder, 'states'), - MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), - CACHE_PATH: path.join(storageFolder, 'cache'), - COVERS_PATH: path.join(storageFolder, 'covers'), - TEXTURES_PATH: path.join(storageFolder, 'textures'), - RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), - }; - - await Promise.all(Object.values(view).map(p => ensureDir(p))); - - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); - - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); + args.push("-batch"); } + if (config.get('launchInFullscreen')) + { + args.push("-fullscreen"); + } + args.push(...["-bigpicture", "-portable", "--"]); + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + const configFileContents = await Bun.file(configFile).text(); + + const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); + const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); + const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + + const view = { + BIOS_PATH: biosFolder, + SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), + SAVE_STATES_PATH: path.join(savesFolder, 'states'), + MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'), + CACHE_PATH: path.join(storageFolder, 'cache'), + COVERS_PATH: path.join(storageFolder, 'covers'), + TEXTURES_PATH: path.join(storageFolder, 'textures'), + RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(view).map(p => ensureDir(p))); + + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + + await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index fddf25c..b0d0a44 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -12,85 +12,86 @@ import { homedir } from "node:os"; export default class PCSX2Integration implements PluginType { + emulator = "PPSSPP"; + load (ctx: PluginContextType) { - ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.emulator === 'PPSSPP') + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + + if (ctx.source?.type === 'store') { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; - - if (ctx.source?.type === 'store') - { - return { - id: desc.name, - supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] - }; - } - else - { - return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; - } - + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "resolution", "config"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; } }); - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { - if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.metadata.emulatorDir) + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) { - const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; - if (config.get('launchInFullscreen')) - { - args.push("--fullscreen"); - } - - if (ctx.autoValidCommand.emulatorSource === 'store' && !ctx.dryRun) - { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; - - switch (process.platform) - { - case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; - break; - case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; - break; - } - - let ppssppPath = ''; - if (process.platform === 'win32') - { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); - } else - { - //TODO: Use way to set custom memstick path when they support it - ensureDir(path.join(homedir(), '.config', 'ppsspp')); - ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); - } - - ensureDir(ppssppPath); - - if (confPath) - { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); - } - - if (controlsPath) - { - const controlsFileContents = await Bun.file(controlsPath).text(); - await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); - } - } - - return args; + args.push(ctx.autoValidCommand.metadata.romPath); } + + args.push("--escape-exit", "--pause-menu-exit"); + if (config.get('launchInFullscreen')) + { + args.push("--fullscreen"); + } + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + let confPath: string | undefined = undefined; + let controlsPath: string | undefined = undefined; + + switch (process.platform) + { + case "win32": + confPath = configFilePathWin32; + controlsPath = configControlsFilePathWin32; + break; + case 'linux': + confPath = configFilePathLinux; + controlsPath = configControlsFilePathLinux; + break; + } + + let ppssppPath = ''; + if (process.platform === 'win32') + { + ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + } else + { + //TODO: Use way to set custom memstick path when they support it + ensureDir(path.join(homedir(), '.config', 'ppsspp')); + ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM'); + } + + ensureDir(ppssppPath); + + if (confPath) + { + const configFileContents = await Bun.file(confPath).text(); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + } + + if (controlsPath) + { + const controlsFileContents = await Bun.file(controlsPath).text(); + await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); + } + } + + return args; }); } } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2736fe2..2a1c42e 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -147,8 +147,7 @@ export const store = new Elysia({ prefix: '/api/store' }) biosRequirement: emulatorPackage.bios, bios: biosFiles, integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo, - hasUpdate: storeDownloadInfo?.hasUpdate ?? null + storeDownloadInfo: storeDownloadInfo }; return emulator; diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index fba1435..ee1da2b 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -28,7 +28,9 @@ export type PluginDescriptionType = z.infer; export const ActiveGameSchema = z.object({ process: z.any().optional(), - gameId: z.number(), + gameId: z.object({ id: z.string(), source: z.string() }), + source: z.string().optional(), + sourceId: z.string().optional(), name: z.string(), command: z.object({ command: z.string(), startDir: z.string().optional() }) }); diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ec57e71..511ed9b 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -46,7 +46,8 @@ function Error (data: ErrorComponentProps) { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + const router = useRouter(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]); const { shortcuts } = useShortcutContext(); return diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 49011d7..8eac18d 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,13 +1,10 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; -import { useEffect } from 'react'; -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'; -import { gameQuery } from '@queries/romm'; -import { rommApi } from '../scripts/clientApi'; +import { useJobStatus } from '../scripts/utils'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -18,34 +15,33 @@ function RouteComponent () const router = useRouter(); function HandleGoBack () { - router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery(gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (!data) return; - const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - - sub.subscribe((e) => + const { data } = useJobStatus('launch-game', { + onEnded (data) { - if (e.data.status !== 'playing') - { - HandleGoBack(); - } - }); - - return () => + HandleGoBack(); + }, + onWaiting () { - sub.close(); - }; - }, [data?.id]); + HandleGoBack(); + }, + }); + + useBlocker({ shouldBlockFn: () => !!data }); return
    diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 5c76442..89168ea 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -116,7 +116,7 @@ function SettingsMenu (data: {}) const { ref, focusKey } = useFocusable({ focusable: true, focusKey: 'settings-menu', - preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') + preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}` }); return
      void; }) { + const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ ...storeEmulatorDeleteMutation, @@ -202,6 +203,15 @@ function TitleArea (data: { }); } } + + options.push(...data.emulator.validSources.filter(s => s.exists).map(s => ({ + content: `Launch: ${s.type}`, type: 'primary', icon: emulatorStatusIcons[s.type], action (ctx) + { + if (!data.emulator) return; + rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type }); + navigation({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } }); + }, id: `open-${s.type}` + } satisfies DialogEntry))); } const { ref, focusKey, hasFocusedChild } = useFocusable({ diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index a9666be..a1f325d 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -3,7 +3,7 @@ import { RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; -import { AnyRouter, Router, useRouter } from "@tanstack/react-router"; +import { AnyRouter, useRouter } from "@tanstack/react-router"; import { soundMap } from "./audio/audio"; export type ScrollSaveParams = { @@ -267,6 +267,7 @@ export function useJobStatus, onProgress?: (process: number, data: ExtractField, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void, + onWaiting?: () => void, onEnded?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; @@ -306,6 +307,11 @@ export function useJobStatus Date: Sun, 5 Apr 2026 12:46:50 +0300 Subject: [PATCH 06/38] fix: Made store downloads extract in their own folder feat: Implemented cemu integration --- src/bun/api/games/games.ts | 15 +-- .../api/games/services/launchGameService.ts | 97 +++++++++++++------ src/bun/api/jobs/install-job.ts | 5 +- src/bun/api/jobs/launch-game-job.ts | 9 +- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 35 +++++++ .../package.json | 14 +++ .../dolphin.ts | 4 +- .../package.json | 2 +- .../pcsx2.ts | 8 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 17 +++- src/mainview/emulatorjs/emulator.ts | 2 +- 11 files changed, 156 insertions(+), 52 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 7c91954..1618c52 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -10,7 +10,7 @@ import path from "node:path"; import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; +import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; @@ -255,7 +255,8 @@ export default new Elysia() { const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), - columns: { path_fs: true } + columns: { path_fs: true }, + with: { platform: { columns: { es_slug: true } } } }); if (!localGame?.path_fs) @@ -265,13 +266,15 @@ export default new Elysia() const downloadPath = config.get('downloadPath'); const path_fs = path.join(downloadPath, localGame.path_fs); - const stats = await fs.stat(path_fs); - if (stats.isDirectory()) + + const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined); + + if (filesPaths.length <= 0) { - return status("Not Found", "Rom is a folder"); + throw new Error("No Valid Roms Found"); } - return Bun.file(path_fs); + return Bun.file(filesPaths[0]); }, { params: z.object({ source: z.string(), id: z.string() }) }) diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 362ff41..4dde81b 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -67,6 +67,70 @@ export async function getEmulatorsForSystem (systemSlug: string) return Array.from(emulators); } +export async function getRomFilePaths (gamePath: string, systemSlug?: string) +{ + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (!systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } else if (systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; +} + /** * * @param data Uses es-de system slug @@ -96,38 +160,7 @@ export async function getValidLaunchCommands (data: { const downloadPath = config.get('downloadPath'); const gamePath = path.join(downloadPath, data.gamePath); - const validFiles: string[] = []; - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - - const extensionList = system.extension.join(','); - - if (gamePathStat.isDirectory()) - { - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else - { - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } + const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug); function escapeWindowsArg (arg: string): string { diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index a45fe7b..39c4687 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -70,7 +70,8 @@ export class InstallJob implements IJob name: game.title, summary: game.description, system_slug: gameId.system, - extract_path: path.join('roms', gameId.system), + path_fs: path.join('roms', gameId.system, game.title), + extract_path: path.join('roms', gameId.system, game.title), }; break; @@ -218,7 +219,7 @@ export class InstallJob implements IJob source_id: info.source_id, source: this.source, slug: info.slug, - path_fs: info.path_fs, + path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), last_played: info.last_played, platform_id: platformId, igdb_id: info.igdb_id, diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 18b4fd1..c58119c 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,10 +1,11 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; -import { db, events, plugins } from "../app"; +import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq, sql } from "drizzle-orm"; import { spawn } from 'node:child_process'; +import path from "node:path"; export class LaunchGameJob implements IJob, "playing"> { @@ -60,7 +61,9 @@ export class LaunchGameJob implements IJob console.log(data)); @@ -82,6 +85,8 @@ export class LaunchGameJob implements IJob + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + + const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + + args.push(`--mlc=${savesPath}`); + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); + } + + return args; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json new file mode 100644 index 0000000..bbabba6 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -0,0 +1,14 @@ +{ + "name": "com.simeonradivoev.gameflow.cemu", + "displayName": "CEMU Integration", + "version": "0.0.1", + "description": "CEMU Emulator Integration", + "main": "./cemu.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "keywords": [ + "integration", + "emulator", + "wiiu", + "cemu" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 59cc505..d4ec3ca 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -25,7 +25,7 @@ export default class DOLPHINIntegration implements PluginType { const args: string[] = []; - const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); args.push(`--user=${storageFolder}`); args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); @@ -35,7 +35,7 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); - const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json index 07fe38d..146b910 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -8,7 +8,7 @@ "keywords": [ "integration", "emulator", - "wiiu", + "wii", "gc", "dolphin" ] diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 2e944d2..bd3b78f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -49,9 +49,9 @@ export default class PCSX2Integration implements PluginType { const configFileContents = await Bun.file(configFile).text(); - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); + const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); const view = { BIOS_PATH: biosFolder, @@ -70,7 +70,7 @@ export default class PCSX2Integration implements PluginType if (process.platform === 'win32') pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); } diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 654fb2a..644a505 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -191,6 +191,18 @@ export default class RommIntegration implements PluginType return file; })); + let extract_path: string | undefined = undefined; + let path_fs = path.join(rom.fs_path, rom.fs_name); + if (files.length === 1) + { + const name = files[0].file_name.toLocaleLowerCase(); + if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) + { + extract_path = rom.name ?? path.parse(name).name; + path_fs = path.join(rom.fs_path, extract_path); + } + } + const info: DownloadInfo = { platform: { slug: rommPlatform.slug, @@ -204,13 +216,14 @@ export default class RommIntegration implements PluginType ra_id: rom.ra_id ?? undefined, summary: rom.summary ?? undefined, name: rom.name ?? "Unknown", - path_fs: path.join(rom.fs_path, rom.fs_name), + path_fs, source_id: String(rom.id), slug: rom.slug ?? undefined, system_slug: rommPlatform.slug, metadata: rom.metadatum, files, - auth: await this.getAuthToken() + auth: await this.getAuthToken(), + extract_path }; return info; diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index 61e570b..b5a730e 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -28,7 +28,7 @@ window.addEventListener('message', (e) => }); -window.EJS_threads = true; +window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; From 05fafced07c853deb656d7c17d05184c42ee507c Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 6 Apr 2026 00:05:00 +0300 Subject: [PATCH 07/38] feat: Added more ways to detect duplicates feat: Added resolution and widescreen settings feat: Added Xenia and Xemu integration --- bun.lock | 6 ++ package.json | 2 + src/bun/api/games/games.ts | 65 +++++++++++--- src/bun/api/hooks/games.ts | 2 +- src/bun/api/jobs/install-job.ts | 16 +++- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../dolphin.ts | 13 ++- .../PCSX2.ini | 6 +- .../pcsx2.ts | 11 ++- .../linux/ppsspp.ini | 4 +- .../ppsspp.ts | 26 +++++- .../win32/ppsspp.ini | 4 +- .../eeprom.bin | Bin 0 -> 256 bytes .../package.json | 14 +++ .../com.simeonradivoev.gameflow.xemu/xemu.ts | 76 ++++++++++++++++ .../package.json | 15 ++++ .../xenia.ts | 82 ++++++++++++++++++ .../com.simeonradivoev.gameflow.romm/romm.ts | 13 +-- src/bun/api/plugins/register-plugins.ts | 6 ++ src/bun/api/task-queue.ts | 17 ++-- .../components/options/OptionDropdown.tsx | 1 - .../components/options/SettingsDropdown.tsx | 55 ++++++++++++ src/mainview/routes/settings/emulators.tsx | 5 +- src/shared/constants.ts | 5 +- src/shared/types..d.ts | 6 ++ 25 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts create mode 100644 src/mainview/components/options/SettingsDropdown.tsx diff --git a/bun.lock b/bun.lock index b111ebe..1514d12 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", @@ -1516,6 +1518,10 @@ "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], diff --git a/package.json b/package.json index 7b6b2e6..fc73e47 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 1618c52..531dc93 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -201,31 +201,68 @@ export default new Elysia() .groupBy(schema.games.id) .where(and(...where)); - localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); - if (!query.collection_id) + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) + { + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); + + } else { games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameType[] = []; + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - } else - { - const remoteGames: FrontEndGameType[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + games.push(...remoteGames.filter(g => { - if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) + if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); - } else - { - return g; + return false; } + + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; })); } } diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index dd893d1..d276463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -35,7 +35,7 @@ export class GameHooks */ fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; - games: FrontEndGameType[]; + games: FrontEndGameTypeWithIds[]; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 39c4687..18407ea 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -15,6 +15,7 @@ import z from "zod"; import { checkFiles } from "../games/services/utils"; import { ensureDir } from "fs-extra"; import { path7za } from "7zip-bin"; +import slugify from 'slugify'; interface JobConfig { @@ -70,8 +71,8 @@ export class InstallJob implements IJob name: game.title, summary: game.description, system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, game.title), - extract_path: path.join('roms', gameId.system, game.title), + path_fs: path.join('roms', gameId.system, slugify(game.title)), + extract_path: '.', }; break; @@ -104,13 +105,17 @@ export class InstallJob implements IJob }); const downloadedFiles = await downloader.start(); + if (!downloadedFiles) + { + return; + } if (info.extract_path && downloadedFiles) { let progress = 0; const progressDelta = 1 / downloadedFiles.length; for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.extract_path); + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise((resolve, reject) => { const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); @@ -119,7 +124,10 @@ export class InstallJob implements IJob cx.setProgress(progress + p.percent * progressDelta, "extract"); }); - seven.on('error', e => reject(e)); + seven.on('error', e => + { + reject(e); + }); seven.on('end', async () => { await fs.rm(filePath); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 60f8973..f42e221 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -3,7 +3,7 @@ import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; -export default class DOLPHINIntegration implements PluginType +export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; @@ -11,7 +11,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -20,7 +20,7 @@ export default class DOLPHINIntegration implements PluginType args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); - const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--mlc=${savesPath}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index d4ec3ca..05fde39 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -13,7 +13,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -35,6 +35,17 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + const resolution = config.get('emulatorResolution'); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6 + }; + args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`); + args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`); + args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index cbafaf8..72985fb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,7 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = false +EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -92,7 +92,7 @@ VsyncEnable = 0 FramerateNTSC = 59.94 FrameratePAL = 50 SyncToHostRefreshRate = false -AspectRatio = Auto 4:3/3:2 +AspectRatio = {{ASPECT_RATIO}} FMVAspectRatioSwitch = Off ScreenshotSize = 0 ScreenshotFormat = 0 @@ -168,7 +168,7 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = 1 +upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index bd3b78f..a5fa18f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -22,7 +22,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -52,6 +52,12 @@ export default class PCSX2Integration implements PluginType const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6, + }; const view = { BIOS_PATH: biosFolder, @@ -62,6 +68,9 @@ export default class PCSX2Integration implements PluginType COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), + ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", + UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 }; await Promise.all(Object.values(view).map(p => ensureDir(p))); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index edd196b..afc914c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index b0d0a44..1f6572f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -10,12 +10,21 @@ import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; -export default class PCSX2Integration implements PluginType +export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; load (ctx: PluginContextType) { + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + if (process.platform === 'win32') + { + await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + } + }); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; @@ -25,7 +34,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -68,7 +77,7 @@ export default class PCSX2Integration implements PluginType let ppssppPath = ''; if (process.platform === 'win32') { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM'); } else { //TODO: Use way to set custom memstick path when they support it @@ -80,8 +89,17 @@ export default class PCSX2Integration implements PluginType if (confPath) { + const resolutionMapping = { + "720p": "2", + "1080p": "4", + "1440p": "6", + "4k": "8" + }; const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { + RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, + FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" + })); } if (controlsPath) diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index f24ea4b..21a71c3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin new file mode 100644 index 0000000000000000000000000000000000000000..55874b0f314b7e6741c4d0b632dbc235c14dbc26 GIT binary patch literal 256 zcmXR{kM}L+Eiz$Z<=*&3$H2+>`~OMi!t#r+CwM z?q$bGQDzpVh9;&KM&<^lMhpR;Qol+wFidRsa!8r>@pnM4h$f$I5hkZ mh<5i4VQ>l#0WraVi + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + if (config.get('launchInFullscreen')) + { + args.push("-full-screen"); + } + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push("-dvd_path"); + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml'); + let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } }; + if (await Bun.file(configPath).exists()) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + configFile.general.misc ??= {}; + configFile.general.misc.skip_boot_anim = true; + configFile.general.show_welcome = false; + configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox'); + configFile.sys.mem_limit = '128'; + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + if (await fs.exists(biosFolder)) + { + const biosPaths = (await fs.readdir(biosFolder)); + const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx')); + const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx')); + const hardDrive = biosPaths.find(f => f.endsWith('qcow2')); + if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash); + if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom); + if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive); + } + + if (!ctx.dryRun) + { + const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin'); + await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer()); + configFile.sys.files.eeprom_path = eepromPath; + + await Bun.write(configPath, toml.stringify(configFile)); + args.push("-config_path"); + args.push(configPath); + } + + + return args; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json new file mode 100644 index 0000000..a6b3d25 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.xenia", + "displayName": "XENIA Integration", + "version": "0.0.1", + "description": "XENIA Emulator Integration", + "main": "./xenia.ts", + "icon": "https://xenia.jp/images/logo-256x256.png", + "keywords": [ + "integration", + "emulator", + "xbox360", + "xenia", + "xenia-edge" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts new file mode 100644 index 0000000..7257559 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -0,0 +1,82 @@ +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { GameflowHooks } from "@/bun/api/hooks/app"; +import { config } from "@/bun/api/app"; +import path from "node:path"; +import { ensureDir } from "fs-extra"; +import toml, { TomlTable } from 'smol-toml'; +import fs from 'node:fs/promises'; + +export default class XENIAIntegration implements PluginType +{ + emulator = 'XENIA'; + emulatorEdge = 'XENIA-EDGE'; + + async handlePostInstall (ctx: Parameters['0']) + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + } + + async handleLaunch (ctx: Parameters['0']) + { + const args: string[] = []; + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + + if (!ctx.dryRun) + { + await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); + let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} }; + if (await fs.exists(configPath)) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + const resolutionMapping = { + "720p": 1, + "1080p": 2, + "1440p": 3, + "4k": 3 + }; + + configFile.Display.fullscreen = config.get('launchInFullscreen'); + configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; + await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!)); + configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); + configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); + + await Bun.write(configPath, toml.stringify(configFile)); + }; + + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + + return args; + } + + handleEmulatorLaunchSupport (ctx: Parameters['0']): + ReturnType + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + } + + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 644a505..ac5439b 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,7 +2,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -138,7 +138,9 @@ export default class RommIntegration implements PluginType }); games.push(...rommGames.data.items.map(g => { - return this.convertRomToFrontend(g); + const game: FrontEndGameType & { igdb_id?: number; } = this.convertRomToFrontend(g); + game.igdb_id = g.igdb_id ?? undefined; + return game; })); } }); @@ -181,8 +183,9 @@ export default class RommIntegration implements PluginType const files = await Promise.all(rom.files.map(async f => { + getRomContentApiRomsIdContentFileNameGet; const file: DownloadFileEntry = { - url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`), file_name: f.file_name, file_path: f.file_path, size: f.file_size_bytes, @@ -198,8 +201,8 @@ export default class RommIntegration implements PluginType const name = files[0].file_name.toLocaleLowerCase(); if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) { - extract_path = rom.name ?? path.parse(name).name; - path_fs = path.join(rom.fs_path, extract_path); + extract_path = '.'; + path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); } } diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 99b9d17..3c49311 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -3,6 +3,9 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; +import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json'; +import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; +import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; @@ -13,6 +16,9 @@ export default async function register (pluginManager: PluginManager) { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, + { ...cemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu') }, + { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, + { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 2ef241c..f331bb6 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -105,7 +105,7 @@ export class TaskQueue { this.queue = []; this.activeQueue.forEach(c => c.abort()); - return Promise.all(this.activeQueue.map(c => c.promise.promise)); + return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing")))); } } @@ -212,10 +212,15 @@ export class JobContext, TData, TState extends str { this.events.emit('started', { id: this.m_id, job: this }); await this.m_job.start(this); - this.completed = true; - this.events.emit('completed', { id: this.m_id, job: this }); - this.m_promise.resolve(this.m_job.exposeData?.()); - + if (!this.abortSignal.aborted) + { + this.completed = true; + this.events.emit('completed', { id: this.m_id, job: this }); + this.m_promise.resolve(this.m_job.exposeData?.()); + } else + { + this.m_promise.resolve(undefined); + } } catch (error) { if (error !== 'cancel') @@ -225,7 +230,7 @@ export class JobContext, TData, TState extends str this.events.emit('error', { id: this.m_id, job: this, error }); this.error = error; - this.m_promise.reject(error); + this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index a661739..239f70c 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -8,7 +8,6 @@ import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionDropdown (data: { name: string; - type: HTMLInputTypeAttribute; className?: string; placeholder?: string; icon?: JSX.Element; diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx new file mode 100644 index 0000000..91f8451 --- /dev/null +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -0,0 +1,55 @@ +import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; +import { SettingsType } from "../../../shared/constants"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { OptionSpace } from "./OptionSpace"; +import { OptionInput } from "./OptionInput"; +import { getSettingQuery, setSettingMutation } from "@queries/settings"; +import { OptionDropdown } from "./OptionDropdown"; + +export function SettingsDropdown (data: { + label: string; + id: KeysWithValueAssignableTo; + values: string[]; + placeholder?: string; + icon?: JSX.Element; + children?: any; +}) +{ + const [dirty, setDirty] = useState(false); + const [localValue, setLocalValue] = useState(); + const { data: serverValue } = useQuery(getSettingQuery(data.id)); + const setMutation = useMutation(setSettingMutation(data.id)); + + useEffect(() => + { + setLocalValue(serverValue as any); + setDirty(false); + }, [serverValue]); + + const handleSave = useCallback(() => + { + if (dirty) + { + setDirty(false); + setMutation.mutate(localValue); + } + }, [dirty, setDirty, localValue]); + + return ( + + + { + setLocalValue(v); + setMutation.mutate(v); + }} + value={localValue} values={data.values} + /> + {data.children} + + ); +} \ No newline at end of file diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index b5e25c8..6a09f01 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -8,7 +8,7 @@ import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Stor import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; -import { RPC_URL } from '../../../shared/constants'; +import { RPC_URL, SettingsSchema } from '../../../shared/constants'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -19,6 +19,7 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; +import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -328,6 +329,8 @@ function RouteComponent ()
      Preferences
      + +
      Overrides
      {!!customEmulators && customEmulators.map((key) => )} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3eff7d5..dfcd52a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,6 @@ +import { emulators } from '@/bun/api/schema/emulators'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; @@ -35,7 +36,9 @@ export const SettingsSchema = z.object({ windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), downloadPath: z.string(), launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]) + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) }); export const LocalSettingsSchema = z.object({ diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index e41afc6..a417dce 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -153,6 +153,12 @@ declare interface FrontEndPlatformType paths_screenshots: string[]; } +declare interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null, From 02a4f2c9a9aff8f7282c531de0e2797dacc2c2f8 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 6 Apr 2026 00:13:53 +0300 Subject: [PATCH 08/38] refactor: Removed unused vars and imports --- package.json | 3 ++- src/bun/api/auth.ts | 2 +- src/bun/api/games/services/statusService.ts | 4 ++-- src/bun/api/games/services/utils.ts | 3 +-- src/bun/api/hooks/emulators.ts | 1 - src/bun/api/jobs/launch-game-job.ts | 5 ++--- .../com.simeonradivoev.gameflow.dolphin/dolphin.ts | 2 +- .../com.simeonradivoev.gameflow.pcsx2/pcsx2.ts | 12 ++++++++---- .../com.simeonradivoev.gameflow.xemu/xemu.ts | 2 -- .../sources/com.simeonradivoev.gameflow.romm/romm.ts | 7 +++++-- src/bun/api/system.ts | 2 +- src/bun/types/typesc.schema.ts | 1 - src/bun/utils/downloader.ts | 2 +- src/mainview/components/AutoFocus.tsx | 2 +- src/mainview/components/FocusTooltip.tsx | 2 +- src/mainview/components/LoadMoreButton.tsx | 1 - src/mainview/components/options/LocalOption.tsx | 1 - src/mainview/components/options/OptionDropdown.tsx | 2 +- src/mainview/components/options/SettingsDropdown.tsx | 3 +-- src/mainview/components/store/StoreEmulatorCard.tsx | 4 +--- src/mainview/routes/game/$source.$id.tsx | 6 +++--- src/mainview/routes/settings/accounts.tsx | 2 +- src/mainview/routes/settings/emulators.tsx | 4 ++-- src/mainview/routes/store/details.emulator.$id.tsx | 4 ++-- src/mainview/routes/store/tab/emulators.tsx | 4 ++-- src/mainview/routes/store/tab/route.tsx | 1 - src/shared/constants.ts | 1 - 27 files changed, 39 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index fc73e47..6869a4b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", - "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts" + "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", + "tsc": "tsc --noEmit" }, "dependencies": { "7zip-bin": "^5.2.0", diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index b171ed0..6cf37eb 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { config, events, jar, plugins, taskQueue } from "./app"; +import { config, events, plugins, taskQueue } from "./app"; import z from "zod"; import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm"; import secrets from '../api/secrets'; diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 4f1d99b..79aaa10 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,9 +1,9 @@ import { RPC_URL, } from "@shared/constants"; -import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; +import { config, db, emulatorsDb, plugins, taskQueue } from "../../app"; import { findExecs, getValidLaunchCommands } from "./launchGameService"; import * as emulatorSchema from '@schema/emulators'; import { and, eq } from "drizzle-orm"; -import { getErrorMessage, hashFile } from "@/bun/utils"; +import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch } from "./utils"; import fs from 'node:fs/promises'; import { getStoreGameFromId } from "../../store/services/gamesService"; diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index f40eb8a..cb53377 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -5,10 +5,9 @@ import { config, db, emulatorsDb, plugins } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; import { StoreGameType } from "@shared/constants"; -import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; import * as emulatorSchema from "@schema/emulators"; import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; -import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; +import { hashFile } from "@/bun/utils"; export async function calculateSize (installPath: string | null) { diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index b968197..6740b30 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,6 +1,5 @@ import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; -import { any } from "zod"; interface EmulatorPostInstallContext { diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index c58119c..183e985 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,11 +1,10 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; -import { config, db, events, plugins } from "../app"; +import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; -import path from "node:path"; export class LaunchGameJob implements IJob, "playing"> { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 05fde39..038cdfb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,5 +1,5 @@ -import { config, db } from "@/bun/api/app"; +import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index a5fa18f..5317395 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,5 +1,5 @@ -import { config, db } from "@/bun/api/app"; +import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import configFile from './PCSX2.ini' with { type: 'file' }; import Mustache from 'mustache'; @@ -59,7 +59,7 @@ export default class PCSX2Integration implements PluginType "4k": 6, }; - const view = { + const paths = { BIOS_PATH: biosFolder, SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), SAVE_STATES_PATH: path.join(savesFolder, 'states'), @@ -68,13 +68,17 @@ export default class PCSX2Integration implements PluginType COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + }; + + await Promise.all(Object.values(paths).map(p => ensureDir(p))); + + const view = { + ...paths, ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 }; - await Promise.all(Object.values(view).map(p => ensureDir(p))); - let pscx2Path = ''; if (process.platform === 'win32') pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index d81722a..49e56f3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,9 +1,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { GameflowHooks } from "@/bun/api/hooks/app"; import { config } from "@/bun/api/app"; import path from "node:path"; -import { ensureDir } from "fs-extra"; import toml, { TomlTable } from 'smol-toml'; import fs from 'node:fs/promises'; import bin from './eeprom.bin' with { type: 'file' }; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ac5439b..2b3621c 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -138,8 +138,11 @@ export default class RommIntegration implements PluginType }); games.push(...rommGames.data.items.map(g => { - const game: FrontEndGameType & { igdb_id?: number; } = this.convertRomToFrontend(g); - game.igdb_id = g.igdb_id ?? undefined; + const game: FrontEndGameTypeWithIds = { + ...this.convertRomToFrontend(g), + igdb_id: g.igdb_id, + ra_id: g.ra_id + }; return game; })); } diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index aa9207e..720a10b 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -10,7 +10,7 @@ import path, { dirname } from "node:path"; import { DirSchema, SystemInfoSchema } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; -import si, { battery } from 'systeminformation'; +import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; export const system = new Elysia({ prefix: '/api/system' }) diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index ee1da2b..aafc779 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -1,6 +1,5 @@ import z from "zod"; import { GameflowHooks } from "../api/hooks/app"; -import { ChildProcess } from "node:child_process"; export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 2a014b9..8d443c2 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -1,4 +1,4 @@ -import { ensureDir, move } from "fs-extra"; +import { ensureDir } from "fs-extra"; import path from 'node:path'; import fs from 'node:fs/promises'; diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 8c42502..74c6da4 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,5 +1,5 @@ import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { useEffect, useLayoutEffect } from "react"; +import { useLayoutEffect } from "react"; export function AutoFocus (data: { parentKey?: string; diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 6916c1e..28f4cf1 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -1,4 +1,4 @@ -import { Ref, RefObject, useEffect, useState } from "react"; +import { RefObject, useState } from "react"; import { useFocusEventListener } from "../scripts/spatialNavigation"; import useActiveControl from "../scripts/gamepads"; import { twMerge } from "tailwind-merge"; diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index 84db100..afcd9b7 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,7 +1,6 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; -import { useEffect } from "react"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 72de9ef..7e55506 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -21,7 +21,6 @@ export function LocalOption (data: { {data.type === 'dropdown' && data.values && diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 239f70c..e2ed246 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -1,4 +1,4 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, JSX, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx index 91f8451..563b859 100644 --- a/src/mainview/components/options/SettingsDropdown.tsx +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -1,8 +1,7 @@ -import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; +import { JSX, useCallback, useEffect, useState } from "react"; import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; -import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; import { OptionDropdown } from "./OptionDropdown"; diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index a9f7720..d9b81f7 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -1,11 +1,9 @@ import { twMerge } from "tailwind-merge"; import { RPC_URL } from "@/shared/constants"; -import { Button } from "../options/Button"; -import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { BadgeCheck, ChevronRight, CircleFadingArrowUp, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; +import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 511ed9b..5f9246c 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,6 +1,6 @@ -import { createFileRoute, ErrorComponentProps, useRouter, useRouterState } from "@tanstack/react-router"; +import { createFileRoute, ErrorComponentProps, useRouter } from "@tanstack/react-router"; import { RPC_URL } from "@shared/constants"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI, StickyHeaderUI } from "../../components/Header"; @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; -import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 841702b..4ac5625 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -7,7 +7,7 @@ import import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import classNames from "classnames"; -import { Key, Link, Lock, LogIn, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; +import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react"; import { useEffect, diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 6a09f01..f9921a3 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -2,9 +2,9 @@ import { createFileRoute, useRouter } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { JSX, useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash, TriangleAlert } from 'lucide-react'; +import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 3bbfd74..b2543d7 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useFocusable, @@ -17,7 +17,7 @@ import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; -import { HandleGoBack, scrollIntoViewHandler, useJobStatus, useOnNavigateBack } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils"; import toast from "react-hot-toast"; import { getErrorMessage } from "react-error-boundary"; import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard"; diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 7d1aafd..524e20a 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,7 +1,7 @@ -import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router'; -import { Joystick, TriangleAlert } from 'lucide-react'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { Joystick } from 'lucide-react'; import { useContext, useEffect } from 'react'; import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard'; diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 2f8f091..2171770 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -95,7 +95,6 @@ function RouteComponent () }; const { shortcuts } = useShortcutContext(); - const { focus } = Route.useSearch(); const handleDetails = (type: string, source: string, id: string, focus: string) => { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index dfcd52a..b7e3236 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,5 @@ -import { emulators } from '@/bun/api/schema/emulators'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; From 54dd9256e361877d0950a84061d9402616706352 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 7 Apr 2026 15:28:56 +0300 Subject: [PATCH 09/38] feat: implemented haptics feat: Implemented a select menu fix: Only used audio clips compile --- README.md | 1 + scripts/generate-audio-sprites.ts | 29 +- src/mainview/App.tsx | 2 +- src/mainview/assets/intro.ogg | 3 + src/mainview/assets/sounds.json | 326 +++--------------- src/mainview/assets/sounds.ogg | 4 +- src/mainview/components/CardList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 11 +- src/mainview/components/ContextDialog.tsx | 15 +- src/mainview/components/Error.tsx | 5 +- src/mainview/components/FocusDots.tsx | 4 +- src/mainview/components/NotFound.tsx | 5 +- src/mainview/components/SelectMenu.tsx | 106 ++++++ src/mainview/components/Shortcuts.tsx | 42 ++- src/mainview/components/game/MainActions.tsx | 4 +- .../components/options/LocalOption.tsx | 26 +- .../components/options/OptionInput.tsx | 110 +++++- .../components/options/OptionSpace.tsx | 10 +- src/mainview/routes/embedded.$source.$id.tsx | 18 +- src/mainview/routes/game/$source.$id.tsx | 21 +- src/mainview/routes/index.tsx | 27 +- src/mainview/routes/launcher.$source.$id.tsx | 10 +- src/mainview/routes/settings/interface.tsx | 2 + src/mainview/routes/settings/route.tsx | 19 +- .../routes/store/details.emulator.$id.tsx | 8 +- src/mainview/routes/store/tab/index.tsx | 16 +- src/mainview/routes/store/tab/route.tsx | 15 +- src/mainview/scripts/audio/audio.ts | 41 +-- src/mainview/scripts/audio/audioConstants.ts | 23 ++ src/mainview/scripts/contexts.ts | 11 +- ...audioCallbacks.ts => feedbackCallbacks.ts} | 14 +- src/mainview/scripts/gamepads.ts | 43 ++- src/mainview/scripts/shortcuts.ts | 1 + src/mainview/scripts/spatialNavigation.ts | 2 +- src/mainview/scripts/types.ts | 1 + src/mainview/scripts/utils.ts | 15 +- src/mainview/types.d.ts | 8 + src/shared/constants.ts | 4 +- src/sounds/UI SFX_InGameMenu_Open.ogg | 3 + src/sounds/UI_Flourish Down_Set 14_01.wav | 3 + src/sounds/UI_Flourish Up_Set 14_01.wav | 3 + src/sounds/UI_Single_Set 11_01.wav | 3 + src/sounds/UI_Single_Set 11_02.wav | 3 + src/sounds/UI_Single_Set 11_03.wav | 3 + src/sounds/UI_Single_Set 5_02.wav | 3 + src/sounds/UI_TwoNote Down_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Down_Set 14_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_02.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_03.wav | 3 + src/sounds/UI_TwoNote Up_Set 14_01.wav | 3 + 51 files changed, 580 insertions(+), 466 deletions(-) create mode 100644 src/mainview/assets/intro.ogg create mode 100644 src/mainview/components/SelectMenu.tsx create mode 100644 src/mainview/scripts/audio/audioConstants.ts rename src/mainview/scripts/{audio/audioCallbacks.ts => feedbackCallbacks.ts} (81%) create mode 100644 src/sounds/UI SFX_InGameMenu_Open.ogg create mode 100644 src/sounds/UI_Flourish Down_Set 14_01.wav create mode 100644 src/sounds/UI_Flourish Up_Set 14_01.wav create mode 100644 src/sounds/UI_Single_Set 11_01.wav create mode 100644 src/sounds/UI_Single_Set 11_02.wav create mode 100644 src/sounds/UI_Single_Set 11_03.wav create mode 100644 src/sounds/UI_Single_Set 5_02.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 14_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_02.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_03.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 14_01.wav diff --git a/README.md b/README.md index 040a745..e3b2c46 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,4 @@ Focused on building a simple user experience and intuitive UI as a curated commu - UI Sounds - [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1) - [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx) + - [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx) diff --git a/scripts/generate-audio-sprites.ts b/scripts/generate-audio-sprites.ts index bcce3e8..1625362 100644 --- a/scripts/generate-audio-sprites.ts +++ b/scripts/generate-audio-sprites.ts @@ -1,24 +1,31 @@ import audioSprite from 'audiosprite'; -import { $, which } from 'bun'; -import fs from "node:fs/promises"; +import { $ } from 'bun'; import path from 'node:path'; +import { soundMap } from '../src/mainview/scripts/audio/audioConstants'; -var files = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +const files = Object.values(soundMap).map(v => +{ + const existingFile = allFiles.find(f => f.startsWith(v.key)); + if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`); + const filePath = path.join(path.resolve('./src/sounds'), existingFile); + return filePath; +}); console.log("Loaded", files.join(",")); await new Promise((resolve) => { - audioSprite( - files.map(f => path.join(path.resolve('./src/sounds'), f)), + audioSprite(files, { output: path.resolve('./src/mainview/assets/sounds'), path: path.resolve('./src/sounds'), format: 'howler', export: 'ogg' - }, async function (err, obj: any) - { - if (err) return console.error(err); - delete obj.urls; - Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); - }); + }, + async function (err, obj: any) + { + if (err) return console.error(err); + delete obj.urls; + Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); + }); }); \ No newline at end of file diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index fb0d2db..76dcda3 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -1,7 +1,7 @@ import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { Router } from "."; import { useEffect } from "react"; -import audioCallbacks from "./scripts/audio/audioCallbacks"; +import audioCallbacks from "./scripts/feedbackCallbacks"; import { client as rommClient } from "../clients/romm/client.gen"; import { RPC_URL } from "@/shared/constants"; diff --git a/src/mainview/assets/intro.ogg b/src/mainview/assets/intro.ogg new file mode 100644 index 0000000..e1505f9 --- /dev/null +++ b/src/mainview/assets/intro.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:231ac69f71f4a0a770ae4bbfd42db9ea136dad6813ddae68a211c74a16e21778 +size 74296 diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json index ae3bae3..97b63f9 100644 --- a/src/mainview/assets/sounds.json +++ b/src/mainview/assets/sounds.json @@ -1,304 +1,64 @@ { "sprite": { - "Classic UI SFX - Chords #1": [ + "Classic UI SFX - Chords #2": [ 0, 4005.215419501134 ], - "Classic UI SFX - Chords #10": [ - 6000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #11": [ - 12000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #12": [ - 18000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #13": [ - 24000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #14": [ - 30000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #15": [ - 36000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #16": [ - 42000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #17": [ - 48000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #18": [ - 54000, - 4005.215419501134 - ], - "Classic UI SFX - Chords #19": [ - 60000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #2": [ - 66000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #20": [ - 72000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #3": [ - 78000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #4": [ - 84000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #5": [ - 90000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #6": [ - 96000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #7": [ - 102000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #8": [ - 108000, - 4005.215419501127 - ], - "Classic UI SFX - Chords #9": [ - 114000, - 4005.215419501127 - ], - "Classic UI SFX - Short - High #1": [ - 120000, - 2546.893424036284 - ], - "Classic UI SFX - Short - High #10": [ - 124000, - 2552.0861678004535 - ], - "Classic UI SFX - Short - High #11": [ - 128000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #12": [ - 132000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #13": [ - 136000, - 3000 - ], - "Classic UI SFX - Short - High #14": [ - 140000, - 2802.0861678004394 - ], - "Classic UI SFX - Short - High #15": [ - 144000, - 2723.9455782312803 - ], - "Classic UI SFX - Short - High #16": [ - 148000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - High #17": [ - 152000, - 2880.226757369627 - ], - "Classic UI SFX - Short - High #18": [ - 156000, - 2359.387755102034 - ], - "Classic UI SFX - Short - High #19": [ - 160000, - 3052.0861678004394 - ], - "Classic UI SFX - Short - High #2": [ - 165000, - 2843.7641723355964 - ], - "Classic UI SFX - Short - High #20": [ - 169000, - 2015.6462585034092 - ], - "Classic UI SFX - Short - High #21": [ - 173000, - 2005.215419501127 - ], - "Classic UI SFX - Short - High #22": [ - 177000, - 2489.5918367346894 - ], - "Classic UI SFX - Short - High #23": [ - 181000, - 2458.3446712018144 - ], - "Classic UI SFX - Short - High #24": [ - 185000, - 2093.7641723355964 - ], - "Classic UI SFX - Short - High #25": [ - 189000, - 2005.215419501127 - ], - "Classic UI SFX - Short - High #3": [ - 193000, - 2864.6031746031613 - ], - "Classic UI SFX - Short - High #4": [ - 197000, - 3031.2698412698464 - ], - "Classic UI SFX - Short - High #5": [ - 202000, - 2598.9795918367236 - ], - "Classic UI SFX - Short - High #6": [ - 206000, - 2427.0975056689394 - ], - "Classic UI SFX - Short - High #7": [ - 210000, - 2468.752834467125 - ], - "Classic UI SFX - Short - High #8": [ - 214000, - 2916.666666666657 - ], - "Classic UI SFX - Short - High #9": [ - 218000, - 2250 - ], - "Classic UI SFX - Short - Low #1": [ - 222000, - 2010.4308390022538 - ], - "Classic UI SFX - Short - Low #10": [ - 226000, - 3020.8390022675644 - ], - "Classic UI SFX - Short - Low #11": [ - 231000, - 2458.3446712018144 - ], - "Classic UI SFX - Short - Low #12": [ - 235000, - 2901.0430839002197 - ], - "Classic UI SFX - Short - Low #13": [ - 239000, - 2843.7641723355964 - ], - "Classic UI SFX - Short - Low #14": [ - 243000, - 3135.4195011337824 - ], - "Classic UI SFX - Short - Low #15": [ - 248000, - 2703.1292517006877 - ], - "Classic UI SFX - Short - Low #16": [ - 252000, - 2875.011337868472 - ], - "Classic UI SFX - Short - Low #17": [ - 256000, - 2927.0975056689394 - ], - "Classic UI SFX - Short - Low #18": [ - 260000, - 3057.2789115646515 - ], - "Classic UI SFX - Short - Low #19": [ - 265000, - 2473.9455782312803 - ], "Classic UI SFX - Short - Low #2": [ - 269000, - 2583.3333333333144 - ], - "Classic UI SFX - Short - Low #20": [ - 273000, - 2515.646258503409 - ], - "Classic UI SFX - Short - Low #21": [ - 277000, - 2604.172335600879 - ], - "Classic UI SFX - Short - Low #22": [ - 281000, - 3031.2698412698182 - ], - "Classic UI SFX - Short - Low #23": [ - 286000, - 2937.50566893425 - ], - "Classic UI SFX - Short - Low #24": [ - 290000, - 2609.387755102034 - ], - "Classic UI SFX - Short - Low #25": [ - 294000, - 2625.0113378685 - ], - "Classic UI SFX - Short - Low #3": [ - 298000, - 2828.140589569159 - ], - "Classic UI SFX - Short - Low #4": [ - 302000, - 2614.6031746031895 + 6000, + 2583.333333333334 ], "Classic UI SFX - Short - Low #5": [ - 306000, - 3161.4739229024735 + 10000, + 3161.473922902495 ], - "Classic UI SFX - Short - Low #6": [ - 311000, - 2333.3333333333144 + "Classic UI SFX - Short - High #9": [ + 15000, + 2250 ], - "Classic UI SFX - Short - Low #7": [ - 315000, - 2536.4625850340303 + "UI_TwoNote Up_Set 11_01": [ + 21000, + 129.16099773242706 ], - "Classic UI SFX - Short - Low #8": [ - 319000, - 2630.2267573695985 + "UI_TwoNote Up_Set 11_02": [ + 23000, + 250 ], - "Classic UI SFX - Short - Low #9": [ - 323000, - 2697.936507936504 + "Classic UI SFX - Short - High #3": [ + 25000, + 2864.6031746031754 ], - "UI_Single_Set 16_01": [ - 327000, - 309.5918367346826 + "Classic UI SFX - Short - High #19": [ + 29000, + 3052.0861678004535 ], - "UI_Single_Set 16_02": [ - 329000, - 309.5918367346826 + "Classic UI SFX - Short - High #22": [ + 34000, + 2489.5918367346967 + ], + "Classic UI SFX - Chords #16": [ + 38000, + 4005.215419501134 + ], + "Classic UI SFX - Short - High #8": [ + 44000, + 2916.6666666666642 ], "UI_Single_Set 16_03": [ - 331000, - 309.5918367346826 + 48000, + 309.5918367346968 ], - "UI_TwoNote_Set 15_01": [ - 333000, - 335.2380952380827 + "UI_Single_Set 16_01": [ + 50000, + 309.5918367346968 ], - "UI_TwoNote_Set 15_02": [ - 335000, - 309.5918367346826 + "Classic UI SFX - Short - Low #6": [ + 52000, + 2333.3333333333358 + ], + "UI SFX_InGameMenu_Open": [ + 56000, + 2614.104308390026 ] } } \ No newline at end of file diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg index b8e00d5..0b7ac9b 100644 --- a/src/mainview/assets/sounds.ogg +++ b/src/mainview/assets/sounds.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a3bb2f9a59e20e5ea49fec7fca68cda5c9167df332ff25d24c29870af834af7 -size 2229386 +oid sha256:c5dd2b1e23a878efe84694fa354e92e07f9394d88217b0f1d925f3b16f044e55 +size 353897 diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 0518d2c..2744585 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -8,6 +8,8 @@ import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { GamepadButtonEvent } from "../scripts/gamepads"; export interface GameMetaExtra extends GameMeta { @@ -24,10 +26,11 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara preview = data.game.previewUrl; } - const handleAction = () => + const handleAction = (e?: Event) => { data.game.onSelect?.(); data.onAction?.(); + oneShot('click'); }; useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index ac0437f..717e986 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -3,9 +3,9 @@ import { StickyHeaderUI } from './Header'; import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; import { JSX, Suspense } from 'react'; -import Shortcuts from './Shortcuts'; +import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; import { HandleGoBack } from '../scripts/utils'; @@ -13,6 +13,7 @@ import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; import { useRouter } from '@tanstack/react-router'; +import SelectMenu from './SelectMenu'; export interface CollectionsDetailParams { @@ -43,8 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); const handleScroll: GameCardFocusHandler = (cardId, node, details) => { @@ -83,9 +83,10 @@ export function CollectionsDetail (data: CollectionsDetailParams)
      {data.footer}
      - +
    + ); } \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 94d31a8..6024311 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -7,6 +7,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts" import { ContextDialogContext } from "../scripts/contexts"; import { FOCUS_KEYS } from "../scripts/types"; import { oneShot } from "../scripts/audio/audio"; +import { oneShotRumble } from "../scripts/gamepads"; export function ContextList (data: { options?: DialogEntry[]; @@ -18,7 +19,7 @@ export function ContextList (data: { const context = useContext(ContextDialogContext); return
      {data.options?.map(o => )} -
      + {data.showCloseButton !== false &&
      } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
    ; } @@ -85,9 +86,9 @@ export interface DialogEntry shortcuts?: Shortcut[]; } -export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; }) +export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; defaultOpen?: boolean; backdropClassName?: string; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(data.defaultOpen ?? false); const [sourceFocusKey, setSourceFocusKey] = useState(undefined); const handleClose = (value: boolean, newSourceFocusKey?: string) => { @@ -111,7 +112,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla } }; - const dialog = + const dialog = {data.content} ; return { @@ -127,12 +128,13 @@ export function ContextDialog (data: { open: boolean, close: (open: boolean) => void; className?: string; + backdropClassName?: string; preferredChildFocusKey?: string; }) { const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, - focusKey: `${data.id}-context-dialog`, + focusKey: FOCUS_KEYS.CONTEXT_DIALOG(data.id), isFocusBoundary: true, saveLastFocusedChild: !data.preferredChildFocusKey, preferredChildFocusKey: data.preferredChildFocusKey @@ -148,6 +150,7 @@ export function ContextDialog (data: { { focusSelf({ instant: true }); oneShot('openContext'); + oneShotRumble('openContext', { all: true }); } }, [data.open]); @@ -159,7 +162,7 @@ export function ContextDialog (data: { return diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index 6bafd95..81bcb63 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -1,7 +1,7 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import Shortcuts from "./Shortcuts"; +import { FloatingShortcuts } from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; import { ErrorComponentProps, useRouter } from "@tanstack/react-router"; @@ -12,7 +12,6 @@ export default function Error (data: ErrorComponentProps) const router = useRouter(); const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); useEffect(() => { focusSelf({ instant: true }); }, []); @@ -30,7 +29,7 @@ export default function Error (data: ErrorComponentProps)
    -
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 0fc2af1..192e388 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -51,7 +51,7 @@ export default function FocusDots (data: { { const focused = em === focusedKey; return ; }); @@ -69,7 +69,7 @@ export default function FocusDots (data: { } }, [data.elements, data.scrollElement?.current]); - return
    + return
    {elements}
    ; } \ No newline at end of file diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index 0172729..6985609 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -1,10 +1,10 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import Shortcuts from "./Shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; import { useRouter } from "@tanstack/react-router"; +import { FloatingShortcuts } from "./Shortcuts"; export default function NotFound () { @@ -12,7 +12,6 @@ export default function NotFound () const router = useRouter(); const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); useEffect(() => { focusSelf({ instant: true }); }, []); @@ -27,7 +26,7 @@ export default function NotFound ()
    -
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx new file mode 100644 index 0000000..4e68b68 --- /dev/null +++ b/src/mainview/components/SelectMenu.tsx @@ -0,0 +1,106 @@ +import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react"; +import { systemApi } from "../scripts/clientApi"; +import { FOCUS_KEYS } from "../scripts/types"; + +export default function SelectMenu (data: { rootFocusKey: string; }) +{ + const navigate = useNavigate(); + const routeState = useRouterState(); + const matchRoute = useMatchRoute(); + + const options: DialogEntry[] = [ + { + content: "Home", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/" }); + }, + selected: !!matchRoute({ to: '/' }), + type: "primary", + id: "home-m" + }, + { + content: "Library", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/games" }); + }, + selected: !!matchRoute({ to: '/games' }), + type: "secondary", + id: "library-m" + }, + { + content: "Store", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/store/tab" }); + }, + selected: !!matchRoute({ to: '/store/tab' }), + type: "info", + id: "store-m" + }, + { + content: "Settings", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/accounts" }); + }, + selected: !!matchRoute({ to: '/settings/accounts' }), + type: "accent", + id: "settings-m" + }, + { + content: "Reload", + icon: , + action (ctx) + { + setOpen(false); + navigation.reload(); + }, + type: "accent", + id: "reload-m" + }, + { + content: "Quit", + icon: , + action (ctx) + { + systemApi.api.system.exit.post(); + }, + type: 'error', + id: "quit-m" + } + ]; + const { dialog, setOpen, open } = useContextDialog('select-menu', { + content: , + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none', + preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') + }); + useShortcuts(data.rootFocusKey, () => [{ + label: "Menu", side: 'left', button: GamePadButtonCode.Select, action (e) + { + if (open) + { + setOpen(false); + } else + { + setOpen(true, getCurrentFocusKey()); + } + + }, + }], [open]); + + return <>{dialog}; +} \ No newline at end of file diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index d8fc94c..03d5e03 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -1,9 +1,16 @@ +import { useContext } from 'react'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; -import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; +import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; +import { ShortcutsContext } from '../scripts/contexts'; -export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) +export function FloatingShortcuts () +{ + return
    ; +} + +export default function Shortcuts (data: { centerElement?: any; }) { const iconMap: Record = { [GamePadButtonCode.A]: 'steamdeck_button_a', @@ -47,15 +54,28 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; + const { shortcuts } = useShortcutContext(); return ( -
    - {data.shortcuts?.filter(s => !!s.label).map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} - label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> - )} -
    + <> +
    + {shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : iconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
    + {data.centerElement} +
    + {shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : iconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
    + ); } diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 2ee89c6..d4067ee 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -21,7 +21,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }, onSuccess (data, { source, id }, onMutateResult, context) { - router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); + router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); }, }); const ws = useRef<{ send: (data: string) => void; }>(undefined); @@ -108,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (cmd.emulator === 'EMULATORJS') { const params = new URLSearchParams(cmd.command); - router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); + router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) }); } else { playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 7e55506..8636bf1 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -9,13 +9,18 @@ export function LocalOption (data: { label: string; id: keyof LocalSettingsType; type: HTMLInputTypeAttribute | 'dropdown'; + min?: number; + max?: number; + step?: number; placeholder?: string; values?: string[]; icon?: JSX.Element; children?: any; }) { - const [localValue, setLocalValue] = useLocalStorage(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) }); + const [localValue, setLocalValue] = useLocalStorage(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { + deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) + }); return ( @@ -25,30 +30,21 @@ export function LocalOption (data: { defaultValue={localValue} onChange={(v) => { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} {data.type !== 'dropdown' && { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 2181b93..31c1d27 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -1,10 +1,11 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useEffect, useRef, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; export function OptionInput (data: { name: string; @@ -12,11 +13,14 @@ export function OptionInput (data: { className?: string; placeholder?: string; icon?: JSX.Element; - value?: string | boolean; - defaultValue?: string | boolean; + value?: string | boolean | number; + min?: number; + max?: number; + step?: number; + defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; onBlur?: FocusEventHandler; - onChange?: (value: any) => void; + onChange?: (value: string | number | boolean) => void; }) { const handlePress = () => @@ -30,16 +34,74 @@ export function OptionInput (data: { } oneShot('click'); }; - const { ref } = useFocusable({ - focusKey: data.name, onEnterPress: handlePress - }); + const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); + const { ref, focusKey } = useFocusable({ + focusKey: data.name, + onEnterPress: handlePress, + onBlur: () => inputRef.current?.blur() + }); + const option = useOptionContext({ onOptionEnterPress: handlePress, }); - const handleFocus = () => + + useEffect(() => + { + if (data.type === 'range') + { + option.setFocusBoundary(inputFocused); + option.setFocusBoundaryDirections(['left', 'right']); + } + }, [inputFocused, option, data.type]); + + useShortcuts(focusKey, () => + { + + const shortcuts: Shortcut[] = []; + if (inputFocused && data.type === 'range') + { + shortcuts.push( + { + label: "Decrease", + button: GamePadButtonCode.Left, + action () + { + if (!inputRef.current) return; + inputRef.current?.stepDown(); + data.onChange?.(inputRef.current.valueAsNumber); + } + }, + { + label: "Increase", + button: GamePadButtonCode.Right, + action (e) + { + if (!inputRef.current) return; + inputRef.current?.stepUp(); + data.onChange?.(inputRef.current.valueAsNumber); + } + } + ); + } + if (inputFocused) + { + shortcuts.push({ + label: "Unfocus", + button: GamePadButtonCode.B, + action (e) + { + inputRef.current?.blur(); + } + }); + } + return shortcuts; + }, [inputFocused, data.type]); + + const handleInputFocus = () => { option.focus(); + setInputFocused(true); if (inputRef.current) { var rect = inputRef.current?.getBoundingClientRect(); @@ -52,25 +114,47 @@ export function OptionInput (data: { } }; + const handleInputBlur = (e: any) => + { + data.onBlur?.(e); + setInputFocused(false); + }; + return (
  • { const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id)); @@ -133,7 +136,13 @@ function RouteComponent () function HandleGoBack () { - router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } useEventListener('message', e => @@ -172,7 +181,6 @@ function RouteComponent () } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); - const { shortcuts } = useShortcutContext(); useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]); function handleClose () { @@ -185,9 +193,7 @@ function RouteComponent ()
    -
    - -
    + ; } \ No newline at end of file diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 5f9246c..ab8c857 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -6,7 +6,7 @@ import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from " import { HeaderUI, StickyHeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; -import Shortcuts from "../../components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils"; @@ -22,6 +22,7 @@ import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; +import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -47,8 +48,7 @@ function Error (data: ErrorComponentProps) const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); const router = useRouter(); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }]); return
    @@ -60,12 +60,6 @@ function Error (data: ErrorComponentProps)
    {JSON.stringify(data.error, null, 3)}
    -
    - -
    - -
    -
    @@ -151,13 +145,11 @@ function RouteComponent () const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); useShortcuts(focusKey, () => [{ - label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) + label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); useOnNavigateBack((s) => s.sound = 'returnDetails'); - const { shortcuts } = useShortcutContext(); - const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ @@ -211,11 +203,10 @@ function RouteComponent () }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} /> + -
    - -
    +
    ); diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index fade167..eee1194 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -34,7 +34,6 @@ import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; -import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; @@ -46,6 +45,8 @@ import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; +import { FloatingShortcuts } from "../components/Shortcuts"; +import SelectMenu from "../components/SelectMenu"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -94,7 +95,7 @@ function ShowAllGamesCard () const router = useRouter(); const handleNavigate = () => { - router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + router.navigate({ to: '/games' }); }; const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); return
    All Games
    ; @@ -231,22 +232,22 @@ function MainMenu () > router.navigate({ to: "/games" })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.type } })} icon={} label="Home" type="secondary" /> } label="News" /> - } action={() => router.navigate({ to: "/store/tab" })} label="Shop" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" /> } label="Album" /> } label="Controllers" /> + onAction={(e) => { - router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } }); }} icon={} label="Settings" @@ -258,15 +259,14 @@ function MainMenu () } function CircleIcon (data: { - action?: () => void; type?: "secondary" | "accent" | "info"; label?: string; icon?: JSX.Element; -}) +} & InteractParams) { - const handleAction = () => + const handleAction = (e?: Event) => { - data.action?.(); + data.onAction?.(e); oneShot('click'); }; const { ref, focusKey } = useFocusable({ @@ -284,7 +284,7 @@ function CircleIcon (data: {
  • handleAction(e.nativeEvent)} className={twMerge( `portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])} > @@ -309,7 +309,6 @@ export default function ConsoleHomeUI () const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); - const { shortcuts } = useShortcutContext(); const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); @@ -348,9 +347,9 @@ export default function ConsoleHomeUI ()
    - +
    - + ); diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 8eac18d..63f0907 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -3,11 +3,14 @@ import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import Shortcuts from '../components/Shortcuts'; +import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, + staticData: { + enterSound: 'launch' + }, }); function RouteComponent () @@ -28,7 +31,6 @@ function RouteComponent () const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); const { data } = useJobStatus('launch-game', { onEnded (data) @@ -48,8 +50,6 @@ function RouteComponent ()

    Launching {data?.name} ...

    -
    - -
    + ; } diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index ddca3a8..9b930e4 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -20,6 +20,8 @@ function RouteComponent () + + ; } diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 89168ea..2385ed6 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -27,10 +27,11 @@ import { twMerge } from "tailwind-merge"; import z from "zod"; import { SettingsSchema } from "../../../shared/constants"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import Shortcuts from "@/mainview/components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import { oneShot } from "@/mainview/scripts/audio/audio"; +import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/settings")({ component: SettingsUI, @@ -55,11 +56,11 @@ function MenuItem (data: { { const router = useRouter(); const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; - const handleNonFocusSelect = () => + const handleNonFocusSelect = (e?: Event) => { if (data.return) { - HandleGoBack(router); + HandleGoBack(router, e); } else if (!acitve) { router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); @@ -88,7 +89,7 @@ function MenuItem (data: { ref={ref} key={data.route} data-sound-category={"menu"} - onClick={data.focusSelect ? focusSelf : handleNonFocusSelect} + onClick={data.focusSelect ? focusSelf : (e) => handleNonFocusSelect(e.nativeEvent)} onFocus={focusSelf} className={twMerge("flex group-focusable cursor-pointer", data.className)} > @@ -180,8 +181,7 @@ export function SettingsUI () preferredChildFocusKey: 'settings-menu' }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); return ( @@ -195,10 +195,13 @@ export function SettingsUI () -
    - +
    +
    + } />
    +
    ); diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index b2543d7..889ee00 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -6,7 +6,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import Shortcuts from "@/mainview/components/Shortcuts"; +import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; @@ -335,7 +335,7 @@ export function RouteComponent () useShortcuts(focusKey, () => [{ label: "Return", - action: () => HandleGoBack(router), + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B }], [router]); @@ -344,8 +344,6 @@ export function RouteComponent () onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); - const { shortcuts } = useShortcutContext(); - const stats: StatEntry[] = []; if (emulator) { @@ -434,7 +432,7 @@ export function RouteComponent () }} games={recommendedGames} />}
    - +
    diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index b596807..2002a08 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -56,11 +56,11 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) return
    - {game ?
    + {game ?
    { @@ -72,13 +72,13 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
    -
    -
    - {!!data.games && } +
    +
    + {!!data.games && }
    -

    {game.name}

    -

    {game.summary}

    +

    {game.name}

    +

    {game.summary}

    @@ -140,7 +140,7 @@ export function RouteComponent ()
    -
    +

    Featured Games diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 2171770..fd2f76b 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,7 +1,8 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; -import Shortcuts from '@/mainview/components/Shortcuts'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; import { gameQuery } from '@/mainview/scripts/queries/romm'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; @@ -19,7 +20,8 @@ export const Route = createFileRoute('/store/tab')({ component: RouteComponent, validateSearch: zodValidator(z.object({ focus: z.string().optional() })), staticData: { - enterSound: 'openStore' + enterSound: 'openStore', + enterHaptic: 'navigateStore' } }); @@ -47,7 +49,7 @@ function TopArea (data: { filters: Record; }) useShortcuts("STORE_ROOT", () => [{ label: "Return", - action: () => HandleGoBack(router), + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B }], [router]); @@ -94,8 +96,6 @@ function RouteComponent () games: { label: "Games", selected: useIsSettings('games') } }; - const { shortcuts } = useShortcutContext(); - const handleDetails = (type: string, source: string, id: string, focus: string) => { if (type === 'emulator') @@ -133,17 +133,16 @@ function RouteComponent ()

    -
    - -
    {!isMobile && <>
    }
    + +
    ; } diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts index 6084ac4..bbf8712 100644 --- a/src/mainview/scripts/audio/audio.ts +++ b/src/mainview/scripts/audio/audio.ts @@ -2,46 +2,36 @@ import { Howl } from 'howler'; import sounds from '../../assets/sounds.ogg'; import soundSprites from '../../assets/sounds.json'; import { getLocalSetting } from '../utils'; +import { hapticMap } from '../gamepads'; +import { soundMap } from './audioConstants'; const timingMap = new Map(); +// Browsers need input to start any sound, so intro doesn't auto play. +/*const introSound = new Howl({ + src: [intro], + volume: getLocalSetting("soundEffectsVolume") / 100, + autoplay: true, +});*/ + const sound = new Howl({ src: [sounds], sprite: soundSprites.sprite as any, - volume: 0.5, + volume: getLocalSetting("soundEffectsVolume") / 100, }); + import.meta.hot?.dispose(() => { sound.unload(); }); declare module '@tanstack/react-router' { interface StaticDataRouteOption { enterSound?: keyof typeof soundMap | null; + enterHaptic?: keyof typeof hapticMap | null; goBackSound?: keyof typeof soundMap | null; } } -const volumeVariation = 0.05; -const rateVariation = 0.01; - -export const soundMap = { - openDetails: { key: 'Classic UI SFX - Chords #1' }, - returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, - returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, - openGeneric: { key: 'Classic UI SFX - Short - High #9' }, - select: { key: 'Classic UI SFX - Short - High #5', rateVariation, volumeVariation }, - selectAlt: { key: "Classic UI SFX - Short - High #6", rateVariation, volumeVariation }, - selectMenu: { key: 'Classic UI SFX - Short - High #7', rateVariation, volumeVariation }, - selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, - closeContext: { key: 'Classic UI SFX - Short - High #19' }, - openContext: { key: 'Classic UI SFX - Short - High #22' }, - openStore: { key: 'Classic UI SFX - Chords #16' }, - openSettings: { key: 'Classic UI SFX - Short - High #8' }, - click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, - clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, - invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, -} satisfies Record; - -function sinRanom () +function sinRandom () { return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); } @@ -63,8 +53,9 @@ export function oneShot (id: keyof typeof soundMap) if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return; const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }; const instanceId = sound.play(soundValue.key); - sound.volume(sound.volume() + random() * (soundValue.volumeVariation ?? 0), instanceId); - sound.rate(1 + random() * (soundValue.rateVariation ?? 0), instanceId); + const baseVolume = getLocalSetting("soundEffectsVolume") / 100; + sound.volume(Math.min(baseVolume * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId); + sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId); timingMap.set(id, new Date()); } diff --git a/src/mainview/scripts/audio/audioConstants.ts b/src/mainview/scripts/audio/audioConstants.ts new file mode 100644 index 0000000..a877e12 --- /dev/null +++ b/src/mainview/scripts/audio/audioConstants.ts @@ -0,0 +1,23 @@ +import soundSprites from '../../assets/sounds.json'; + +const volumeVariation = 0.05; +const rateVariation = 0.02; + +export const soundMap = { + openDetails: { key: 'Classic UI SFX - Chords #2' }, + returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, + returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, + openGeneric: { key: 'Classic UI SFX - Short - High #9' }, + select: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectAlt: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectMenu: { key: "UI_TwoNote Up_Set 11_02", rateVariation, volumeVariation }, + selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, + closeContext: { key: 'Classic UI SFX - Short - High #19' }, + openContext: { key: 'Classic UI SFX - Short - High #22' }, + openStore: { key: 'Classic UI SFX - Chords #16' }, + openSettings: { key: 'Classic UI SFX - Short - High #8' }, + click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, + clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, + invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, + launch: { key: "UI SFX_InGameMenu_Open" } +} satisfies Record; \ No newline at end of file diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 3257bfb..d987199 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,6 +1,7 @@ import { SystemInfoType } from "@/shared/constants"; -import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; +import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; +import { Shortcut } from "./shortcuts"; export const StoreContext = createContext({} as { showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void; @@ -20,6 +21,8 @@ export const OptionContext = createContext( focused: boolean; focus: (focusDetails?: FocusDetails | undefined) => void; eventTarget: EventTarget; + setFocusBoundary: (b: boolean) => void; + setFocusBoundaryDirections: (dirs: Direction[]) => void; }, ); @@ -34,6 +37,12 @@ export const FilePickerContext = createContext<{ activeDrive: Drive | undefined; }>({} as any); +export const ShortcutsContext = createContext({} as { + shortcuts: ({ + key: string; + } & Shortcut)[] | undefined; +}); + export const SystemInfoContext = createContext({} as SystemInfoType | undefined); export const GameDetailsContext = createContext<{ diff --git a/src/mainview/scripts/audio/audioCallbacks.ts b/src/mainview/scripts/feedbackCallbacks.ts similarity index 81% rename from src/mainview/scripts/audio/audioCallbacks.ts rename to src/mainview/scripts/feedbackCallbacks.ts index 4a8f744..f103d68 100644 --- a/src/mainview/scripts/audio/audioCallbacks.ts +++ b/src/mainview/scripts/feedbackCallbacks.ts @@ -1,5 +1,8 @@ import { Router } from "@/mainview"; -import { oneShot, soundMap } from "./audio"; +import { soundMap } from "./audio/audioConstants"; +import { oneShotRumble } from "./gamepads"; +import { oneShot } from "./audio/audio"; + export default function load () { let lastLocationPath: string | undefined; @@ -13,12 +16,18 @@ export default function load () const soundRoute = routes.find(r => r.staticData.enterSound !== undefined); if (soundRoute) { - if (soundRoute.staticData.enterSound) oneShot(soundRoute.staticData.enterSound); + oneShot(soundRoute.staticData.enterSound!); } else { oneShot("openGeneric"); } + if (op.location.state.eventType === 'gamepadbuttondown') + { + const hapticRoute = routes.find(r => r.staticData.enterHaptic !== undefined); + if (hapticRoute) oneShotRumble(hapticRoute.staticData.enterHaptic!, { all: true }); + else oneShotRumble('navigateForward', { all: true }); + } } else if (op.action.type === 'BACK') { if (lastLocationPath) @@ -73,6 +82,7 @@ export default function load () if (e.detail.nativeEvent || e.detail.event) { oneShot(sound); + oneShotRumble('select', { event: e.detail.event }); } }, 10); } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index e7edf3b..15d48a6 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -1,7 +1,7 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation"; import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; -import { mobileCheck } from "./utils"; +import { getLocalSetting, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; let loopStarted = false; @@ -280,4 +280,45 @@ function updateStatus () } requestAnimationFrame(updateStatus); +} + +export const hapticMap = { + select: [{ duration: 50, strongMagnitude: 0, weakMagnitude: 1 }], + navigateForward: [{ duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }, { duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }], + navigateBack: [{ duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + navigateStore: [{ duration: 200, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 300, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + openContext: [{ duration: 50, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.0, weakMagnitude: 0.0 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], +} satisfies Record; + +let lastRumble: AbortController; + +export function oneShotRumble (effect: keyof typeof hapticMap, init?: { event?: Event, all?: boolean; }) +{ + if (!getLocalSetting('hapticsEffects')) return; + + async function play (g: Gamepad) + { + lastRumble = new AbortController(); + for (const e of hapticMap[effect]) + { + await new Promise(resolve => + { + g.vibrationActuator.playEffect('dual-rumble', e); + const timeout = setTimeout(() => resolve(true), e.duration + 50); + lastRumble.signal.onabort = () => clearTimeout(timeout); + if (lastRumble.signal.aborted) resolve(false); + }); + + if (lastRumble.signal.aborted) return; + } + } + + if (lastRumble) lastRumble.abort(); + if (init?.event instanceof GamepadEvent || init?.event instanceof GamepadButtonEvent) + { + if (init?.event.gamepad) play(init?.event.gamepad); + } else if (init?.all) + { + navigator.getGamepads().filter(g => !!g).forEach(g => play(g)); + } } \ No newline at end of file diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 5defdf7..c198037 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -34,6 +34,7 @@ export interface Shortcut button: GamePadButtonCode; heldTime?: number; action?: (e: GamepadButtonEvent) => void; + side?: "left" | "right"; } let isDirty = false; diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 51d212a..d076df4 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -32,7 +32,7 @@ export function GetFocusedElement (focusKey: string) export function GetFocusedTree (leaf: string): string[] { - const tree: string[] = []; + const tree: string[] = ["window"]; let component = (SpatialNavigation as any).focusableComponents[leaf]; while (component) { diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index ca39657..e7630dc 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -6,6 +6,7 @@ export const FOCUS_KEYS = { EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`, EMULATOR_CUSTOM_PATH: (id: string) => `EMULATOR_CUSTOM_PATH_${id}`, CONTEXT_DIALOG_OPTION: (contextId: string, id: string) => `${contextId}_LIST_OPTION${id}`, + CONTEXT_DIALOG: (contextId: string) => `${contextId}_CONTEXT_DIALOG`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, GAME_SECTION: "GAME_SECTION", GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index a1f325d..e84b7b7 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -4,7 +4,8 @@ import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; import { AnyRouter, useRouter } from "@tanstack/react-router"; -import { soundMap } from "./audio/audio"; +import { soundMap } from "./audio/audioConstants"; +import { GamepadButtonEvent, oneShotRumble } from "./gamepads"; export type ScrollSaveParams = { id: string; @@ -60,11 +61,11 @@ export function mobileCheck () return check; }; -export function getLocalSetting (key: TKey) +export function getLocalSetting (key: TKey): LocalSettingsType[TKey] { const localValueRaw = localStorage.getItem(key); - if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined); - return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)); + if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined) as any; + return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)) as any; } export function useLocalSetting (key: TKey) @@ -329,11 +330,15 @@ export function useJobStatus Date: Thu, 9 Apr 2026 17:15:37 +0300 Subject: [PATCH 10/38] feat: Implemented romm saves for dolphin and xenia feat: Implemented save backups for emulatorjs fix: Added support for rar archives fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs --- bun.lock | 8 + package.json | 2 + src/bun/api/auth.ts | 2 +- src/bun/api/clients.ts | 3 +- src/bun/api/controls/controls.ts | 2 +- src/bun/api/emulatorjs/emulatorjs.ts | 62 ++++- src/bun/api/games/services/statusService.ts | 37 ++- src/bun/api/hooks/games.ts | 25 +- src/bun/api/jobs/install-job.ts | 52 +++- src/bun/api/jobs/launch-game-job.ts | 261 +++++++++++------- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 4 +- .../dolphin.ts | 26 +- .../utils.ts | 164 +++++++++++ .../PCSX2.ini | 17 -- .../pcsx2.ts | 52 ++-- .../linux/ppsspp.ini | 2 - .../ppsspp.ts | 50 ++-- .../win32/ppsspp.ini | 2 - .../com.simeonradivoev.gameflow.xemu/xemu.ts | 4 +- .../utils.ts | 141 ++++++++++ .../xenia.ts | 42 ++- .../com.simeonradivoev.gameflow.romm/romm.ts | 146 +++++++++- src/bun/api/task-queue.ts | 24 +- src/mainview/components/Notifications.tsx | 16 +- src/mainview/components/game/Details.tsx | 19 +- src/mainview/emulatorjs/emulator.ts | 59 +++- src/mainview/emulatorjs/types.d.ts | 3 + src/mainview/routes/embedded.$source.$id.tsx | 42 ++- src/mainview/routes/game/$source.$id.tsx | 2 + src/mainview/routes/launcher.$source.$id.tsx | 22 +- .../routes/store/details.emulator.$id.tsx | 17 +- src/mainview/scripts/audio/audio.ts | 1 + src/mainview/scripts/gamepads.ts | 9 +- src/mainview/scripts/utils.ts | 7 +- src/mainview/types.d.ts | 9 +- src/shared/types..d.ts | 12 +- 36 files changed, 1103 insertions(+), 243 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts diff --git a/bun.lock b/bun.lock index 1514d12..0b550d6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -78,6 +79,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", @@ -1278,6 +1280,8 @@ "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], + "node-unrar-js": ["node-unrar-js@2.0.2", "", {}, "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="], + "normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1322,6 +1326,8 @@ "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1376,6 +1382,8 @@ "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/package.json b/package.json index 6869a4b..3d5f135 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", "open": "^11.0.0", "pathe": "^2.0.3", "slugify": "^1.6.9", @@ -118,6 +119,7 @@ "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 6cf37eb..a0740bc 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str body: { password, username, - scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' + scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write' }, baseUrl: host }); diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index 7d117d3..470faf8 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -5,9 +5,10 @@ import games from "./games/games"; import platforms from "./games/platforms"; import auth from "./auth"; import collections from "./games/collections"; +import emulatorjs from "./emulatorjs/emulatorjs"; export default new Elysia({ prefix: "/api/romm" }) - .use([games, platforms, collections, auth]) + .use([games, platforms, collections, auth, emulatorjs]) .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts index 4aa417a..cc3c455 100644 --- a/src/bun/api/controls/controls.ts +++ b/src/bun/api/controls/controls.ts @@ -14,8 +14,8 @@ export default async function Initialize () const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); if (launchGameTask) { - launchGameTask.abort('exit'); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); + launchGameTask.abort('exit'); } else { events.emit('focus'); diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index c8018de..247ce6a 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -1,4 +1,11 @@ // ES-DE to emulator JS mapping + +import Elysia, { status } from "elysia"; +import z from "zod"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; +import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; + // TODO: use the retroarch cores based on ES-DE export const cores: Record = { "atari5200": "atari5200", @@ -43,4 +50,57 @@ export const cores: Record = { "plus4": "plus4", "vic20": "vic20", "dos": "dos" -}; \ No newline at end of file +}; + +export default new Elysia({ prefix: '/emulatorjs' }) + .put('/save', async ({ body: { save, screenshot } }) => + { + await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save); + }, { + body: z.object({ + save: z.file(), + screenshot: z.file().optional() + }) + }).get('/load', async ({ query: { filePath } }) => + { + return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath)); + }, { query: z.object({ filePath: z.string() }) }) + .post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) => + { + const localGame = await getLocalGame(source, id); + if (!localGame) return status("Not Found"); + + const changedSaveFiles: SaveFileChange[] = []; + if (save) + { + const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); + const saveFile = path.join(savesPath, save.name); + await Bun.write(saveFile, save); + changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); + } + await updateLocalLastPlayed(localGame.id); + await plugins.hooks.games.postPlay.promise({ + source, + id, + saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), + gameInfo: { platformSlug: localGame?.platform.slug }, + changedSaveFiles: changedSaveFiles, + validChangedSaveFiles: changedSaveFiles, + command: { + id: "EMULATORJS", + command: "", + emulator: "EMULATORJS", + valid: true, + metadata: { + romPath: localGame?.path_fs ?? undefined, + emulatorBin: undefined, + emulatorDir: undefined + } + } + }); + }, { + body: z.object({ + save: z.file().optional() + }) + }); \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 79aaa10..97f0d8a 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -13,6 +13,7 @@ import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; +import * as appSchema from "@schema/app"; class CommandSearchError extends Error { @@ -26,7 +27,14 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ - columns: { id: true, path_fs: true, source: true, source_id: true }, + columns: { + id: true, + path_fs: true, + source: true, + source_id: true, + igdb_id: true, + ra_id: true + }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }> +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find local game"); + if (localGame.source && localGame.source_id) + { + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); + if (!sourceGame) return { valid: false, reason: "Source Missing" }; + if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined)) + { + return { valid: false, reason: "IGDB Miss Match" }; + } + + if (sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + { + return { valid: false, reason: "RA Miss Match" }; + } + } + + return { valid: true }; +} + +export async function updateLocalLastPlayed (id: number) +{ + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id))); +} + export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { if (source === 'emulator') diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index d276463..38016aa 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -18,8 +18,9 @@ export class GameHooks source?: string; sourceId?: string; id: FrontEndId; + platformSlug?: string; }; - }], string[] | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -69,7 +70,27 @@ export class GameHooks fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); - updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); + prePlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + setProgress: (progress: number, state: string) => void, + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); + postPlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderPath?: string; + changedSaveFiles: SaveFileChange[], + validChangedSaveFiles: SaveFileChange[], + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 18407ea..0564111 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; -import path from 'node:path'; +import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; @@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir } from "fs-extra"; +import { ensureDir, existsSync } from "fs-extra"; import { path7za } from "7zip-bin"; import slugify from 'slugify'; +import StreamZip from 'node-stream-zip'; +import { createExtractorFromFile } from 'node-unrar-js'; +import { which } from "bun"; interface JobConfig { @@ -116,23 +119,62 @@ export class InstallJob implements IJob for (const filePath of downloadedFiles) { const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); - await new Promise((resolve, reject) => + await new Promise(async (resolve, reject) => { - const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); + let sevenZipPath = process.env.ZIP7_PATH ?? path7za; + + if (filePath.endsWith('.rar')) + { + let newPath: string | undefined; + if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe")) + { + newPath = "C:\\Program Files\\7-Zip\\7z.exe"; + } else + { + newPath = which('7z') ?? undefined; + } + + if (!newPath) + { + await fs.rm(filePath); + reject(new Error("No RAR Support")); + return; + } + + sevenZipPath = newPath; + } + + let rejected = false; + const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true }); seven.on('progress', p => { cx.setProgress(progress + p.percent * progressDelta, "extract"); }); - seven.on('error', e => { reject(e); + rejected = true; }); seven.on('end', async () => { + if (rejected) return; await fs.rm(filePath); resolve(true); }); + }).catch(async e => + { + if (filePath.endsWith('.zip')) + { + console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); + await ensureDir(extractPath); + const zip = new StreamZip.async({ file: filePath }); + const count = await zip.extract(null, extractPath); + console.log(`Extracted ${count} entries`); + await zip.close(); + } else + { + throw e; + } }); progress += progressDelta * 100; } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 183e985..b60cb76 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -5,8 +5,11 @@ import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; +import { watch } from "node:fs"; +import fs from "node:fs/promises"; +import { updateLocalLastPlayed } from "../games/services/statusService"; -export class LaunchGameJob implements IJob, "playing"> +export class LaunchGameJob implements IJob, string> { static id = "launch-game" as const; static dataSchema = z.nullable(ActiveGameSchema); @@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob; + saveFolderPath?: string; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob, "playing">, z.infer, "playing">) + async postPlay (gameInfo: { platformSlug?: string; }) { - let gameInfo: { name?: string, source_id?: string, source?: string; }; + if (this.gameId.source === 'local') + { + await updateLocalLastPlayed(Number(this.gameId.id)); + } + + const source = this.gameSource ?? this.gameId.source; + const id = this.gameSourceId ?? this.gameId.id; + + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + saveFolderPath: this.saveFolderPath, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: [], + gameInfo + }).catch(e => console.error(e)); + } + + prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) + { + return plugins.hooks.games.prePlay.promise({ + source: this.gameSource ?? this.gameId.source, + id: this.gameSourceId ?? this.gameId.id, + saveFolderPath: this.saveFolderPath, + command: this.validCommand, + setProgress: setProgress, + gameInfo + }); + } + + async start (context: JobContext, string>, z.infer, string>) + { + let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined; if (this.gameId.source === 'emulator') { gameInfo = { name: this.gameId.id }; @@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob + await new Promise(async (resolve, reject) => { - let game: any; - if (!commandArgs) + try { - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: true, - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { + let game: any; + if (!commandArgs) + { + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e)); + + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(this.validCommand.command, { + shell: true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + reject(e); + }); + + game = spawnGame; + } + else if (this.validCommand.metadata.emulatorBin) + { + this.saveFolderPath = commandArgs.savesPath; + + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); + + // We have full control over launching integrated emulators better to use bun spawn + const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + } + }); + + context.setProgress(0, "playing"); + + if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath)) + { + const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal }); + console.log("Starting To Watch", commandArgs.savesPath, "for save file changes"); + savesWatcher.on('change', (type, filename) => + { + if (typeof filename === 'string') + { + console.log("Save File Changed", filename); + this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! }); + } + }); + + bunGame.exited.then(() => + { + savesWatcher.close(); + console.log("Closing Save File Watching for", commandArgs.savesPath); + }); } - }); - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => + { + console.error(e); + reject(e); + }); + + game = bunGame; + + } else { - resolve(code); - }); - spawnGame.on('error', e => - { - console.error(e); - reject(e); - }); - - game = spawnGame; - } - else if (this.validCommand.metadata.emulatorBin) - { - // We have full control over launching integrated emulators better to use bun spawn - const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { - } - }); - - context.abortSignal.addEventListener('abort', reject); - - bunGame.exited.then(e => - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; - } else - { - reject(new Error("No Emulator Bin")); - return; - } - - this.activeGame = { - process: game, - name: gameInfo?.name ?? "Unknown", - gameId: this.gameId, - source: this.gameSource, - sourceId: this.gameSourceId, - command: this.validCommand - }; - - const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) => - { - if (this.gameId.source === 'local') - { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id))); + reject(new Error("No Emulator Bin")); + return; } - await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v => - { - if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); - }); - }; - - updatePlayed(this.gameId, this.gameSource, this.gameSourceId); + this.activeGame = { + process: game, + name: gameInfo?.name ?? "Unknown", + gameId: this.gameId, + source: this.gameSource, + sourceId: this.gameSourceId, + command: this.validCommand + }; + } catch (e) + { + context.abort(e); + reject(e); + } }); - /* 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'); - }*/ + await this.postPlay({ platformSlug: gameInfo?.platformSlug }); } exposeData () diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index f42e221..cc3cfc7 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return args; + return { args, savesPath: savesPath }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 038cdfb..aa993a3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -3,17 +3,18 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; +import { ensureDir } from "fs-extra"; +import { getSavePaths, getType } from "./utils"; export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -51,14 +52,33 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); + args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`); + if (!ctx.dryRun) + { + await ensureDir(path.join(savesPath, 'GC', "JAP")); + await ensureDir(path.join(savesPath, 'GC', "EUR")); + await ensureDir(path.join(savesPath, 'GC', "USA")); + } + + let finalSavesPath: string | undefined = undefined; if (ctx.autoValidCommand.metadata.romPath) { args.push("--batch"); args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); + + finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return args; + return { args, savesPath: finalSavesPath }; + }); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + } }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts new file mode 100644 index 0000000..2514790 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -0,0 +1,164 @@ +import { join } from "path"; +import { platform } from "os"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type DolphinLocation = + | { type: "path"; toolPath: string; } + | { type: "appimage"; appImagePath: string; }; + +async function findDolphinTool (bundledDir?: string): Promise +{ + const os = platform(); + const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool"; + + if (bundledDir) + { + if (os === "linux") + { + const glob = new Bun.Glob("*.AppImage"); + for await (const file of glob.scan(bundledDir)) + { + return { type: "appimage", appImagePath: join(bundledDir, file) }; + } + throw new Error(`No AppImage found in ${bundledDir}`); + } else + { + return { type: "path", toolPath: join(bundledDir, toolName) }; + } + } + + // Fallback 1: check PATH + const inPath = Bun.which(toolName); + if (inPath) return { type: "path", toolPath: inPath }; + + // Fallback 2: platform default install locations + if (os === "win32") + { + const candidates = [ + "C:/Program Files/Dolphin/DolphinTool.exe", + "C:/Program Files (x86)/Dolphin/DolphinTool.exe", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } + } else if (os === "darwin") + { + const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool"; + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } else if (os === "linux") + { + const home = process.env.HOME ?? ""; + const candidates = [ + join(home, "Applications/Dolphin-x86_64.AppImage"), + join(home, "Applications/Dolphin.AppImage"), + "/opt/Dolphin-x86_64.AppImage", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "appimage", appImagePath: candidate }; + } + } + } + + throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`); +} + +async function runDolphinTool (args: string[], location: DolphinLocation): Promise +{ + if (location.type === "path") + { + const proc = Bun.spawnSync([location.toolPath, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } else + { + const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], { + stdout: "pipe", + stderr: "pipe", + }); + const mountPoint = (await new Response(mount.stdout).text()).trim(); + try + { + const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } finally + { + mount.kill(); + } + } +} + +async function readGameId (romPath: string, location: DolphinLocation): Promise +{ + const output = await runDolphinTool(["header", "-i", romPath], location); + const match = output.match(/Game ID:\s*(\w{6})/); + if (!match) throw new Error("Could not read game ID"); + return match[1]; +} + +function getRegion (regionCode: string) +{ + switch (regionCode) + { + case "E": return "USA"; + case "P": return "EUR"; + case "J": return "JAP"; + default: return "USA"; + } +} + +async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation) +{ + const gameId = await readGameId(romPath, location); + const region = getRegion(gameId[3]); + + const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format + const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E" + const cardPath = join(savesPath, "GC", region); + + const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); + const saves: SaveFileChange[] = []; + for await (const file of glob.scan(cardPath)) + { + saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + } + + return saves; +} + +export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii"> +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + return isGameCube ? "gamecube" : "wii"; +} + +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + + if (isGameCube) + { + return getGCSavePaths(romPath, savesPath, location); + } else + { + const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); + const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); + const files = await fs.readdir(rootFolder, { recursive: true }); + return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index 72985fb..e1403c5 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,6 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -168,7 +167,6 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 @@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 -[Folders] -Bios = {{{BIOS_PATH}}} -Snapshots = {{{SNAPSHOTS_PATH}}} -SaveStates = {{{SAVE_STATES_PATH}}} -MemoryCards = {{{MEMORY_CARDS_PATH}}} -Cache = {{{CACHE_PATH}}} -Covers = {{{COVERS_PATH}}} -Logs = logs -Textures = {{{TEXTURES_PATH}}} -Videos = videos - - [InputSources] Keyboard = true Mouse = true @@ -488,6 +474,3 @@ RDown = SDL-1/+RightY RLeft = SDL-1/-RightX LargeMotor = SDL-1/LargeMotor SmallMotor = SDL-1/SmallMotor - -[GameList] -RecursivePaths = {{{RECURSIVE_PATHS}}} diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 5317395..db405a2 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,11 +1,11 @@ import { config } from "@/bun/api/app"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; -import configFile from './PCSX2.ini' with { type: 'file' }; -import Mustache from 'mustache'; +import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; +import ini from 'ini'; export default class PCSX2Integration implements PluginType { @@ -15,7 +15,7 @@ export default class PCSX2Integration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -47,7 +47,16 @@ export default class PCSX2Integration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - const configFileContents = await Bun.file(configFile).text(); + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + + const configPath = path.join(pscx2Path, 'PCSX2.ini'); + const existingConfigFile = Bun.file(configPath); + + const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text()); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); @@ -67,28 +76,37 @@ export default class PCSX2Integration implements PluginType CACHE_PATH: path.join(storageFolder, 'cache'), COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), + VIDEOS_PATH: path.join(storageFolder, 'videos'), + LOGS_PATH: path.join(storageFolder, 'logs'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), }; await Promise.all(Object.values(paths).map(p => ensureDir(p))); - const view = { - ...paths, - ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), - ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", - UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 - }; + configFile.EmuCore ??= {}; + configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen'); + configFile['EmuCore/GS'] ??= {}; + configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2"; + configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.Folders ??= {}; + configFile.Folders.Bios = paths.BIOS_PATH; + configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH; + configFile.Folders.SaveStates = paths.SAVE_STATES_PATH; + configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH; + configFile.Folders.Cache = paths.CACHE_PATH; + configFile.Folders.Covers = paths.COVERS_PATH; + configFile.Folders.Textures = paths.TEXTURES_PATH; + configFile.Folders.Videos = paths.VIDEOS_PATH; + configFile.Folders.Logs = paths.LOGS_PATH; + configFile.GameList ??= {}; + configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS; - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + await Bun.write(configPath, ini.stringify(configFile)); - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + return { args, savesPath: paths.MEMORY_CARDS_PATH }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index afc914c..c138918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 1f6572f..b6ff93d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -9,6 +9,7 @@ import path from "node:path"; import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; +import ini from 'ini'; export default class PPSSPPIntegration implements PluginType { @@ -27,7 +28,7 @@ export default class PPSSPPIntegration implements PluginType ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; if (ctx.source?.type === 'store') { @@ -59,18 +60,18 @@ export default class PPSSPPIntegration implements PluginType if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) { - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; + let defaultConfigPath: string | undefined = undefined; + let defaultControlsPath: string | undefined = undefined; switch (process.platform) { case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; + defaultConfigPath = configFilePathWin32; + defaultControlsPath = configControlsFilePathWin32; break; case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; + defaultConfigPath = configFilePathLinux; + defaultControlsPath = configControlsFilePathLinux; break; } @@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType ensureDir(ppssppPath); - if (confPath) + if (defaultConfigPath) { - const resolutionMapping = { - "720p": "2", - "1080p": "4", - "1440p": "6", - "4k": "8" + const resolutionMapping: Record = { + "720p": 2, + "1080p": 4, + "1440p": 6, + "4k": 8 }; - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { - RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, - FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" - })); + const configPath = path.join(ppssppPath, 'ppsspp.ini'); + const configFile = Bun.file(configPath); + + const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text()); + + ppssppConfig.Graphics ??= {}; + ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0; + ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen'); + + await Bun.write(configPath, ini.stringify(ppssppConfig)); } - if (controlsPath) + if (defaultControlsPath) { - const controlsFileContents = await Bun.file(controlsPath).text(); + const controlsFileContents = await Bun.file(defaultControlsPath).text(); await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } + + return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index 21a71c3..f448165 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index 49e56f3..010430c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -68,7 +68,7 @@ export default class XEMUIntegration implements PluginType } - return args; + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts new file mode 100644 index 0000000..ceef07e --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts @@ -0,0 +1,141 @@ +import { join } from "path"; +import { platform } from "os"; + +const SECTOR_SIZE = 0x800; +const MAGIC = "MICROSOFT*XBOX*MEDIA"; + +const PARTITION_OFFSETS: Record = { + XSF: 0x0, + GDF: 0xFD90000, + XGD3: 0x2080000, +}; + +async function readBytes (file: ReturnType, offset: number, length: number): Promise +{ + return Buffer.from(await file.slice(offset, offset + length).arrayBuffer()); +} + +async function parseTitleIdFromXexReader ( + read: (offset: number, length: number) => Promise +): Promise +{ + // Read just the fixed header (magic + flags + offsets + header count) + const header = await read(0, 0x18); + if (header.toString("ascii", 0, 4) !== "XEX2") + { + throw new Error("Not a valid XEX2 file"); + } + + const headerCount = header.readUInt32BE(0x14); + const EXEC_INFO_KEY = 0x40006; + + // Read the optional header table + const table = await read(0x18, headerCount * 8); + + for (let i = 0; i < headerCount; i++) + { + const key = table.readUInt32BE(i * 8); + const valueOrOffset = table.readUInt32BE(i * 8 + 4); + + if (key === EXEC_INFO_KEY) + { + // valueOrOffset is a file offset — read the exec info struct there + // TitleID is at +0x0C within it + const execInfo = await read(valueOrOffset, 0x18); + return execInfo.readUInt32BE(0x0C) + .toString(16).toUpperCase().padStart(8, "0"); + } + } + + throw new Error("Execution info header not found in XEX"); +} + +async function titleIdFromXexFile (xexPath: string): Promise +{ + const file = Bun.file(xexPath); + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, offset, length) + ); +} + +async function titleIdFromIso (isoPath: string): Promise +{ + const file = Bun.file(isoPath); + const fileSize = file.size; + + for (const partitionOffset of Object.values(PARTITION_OFFSETS)) + { + const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE; + if (vdOffset + 28 > fileSize) continue; + + const vd = await readBytes(file, vdOffset, 28); + if (vd.toString("ascii", 0, 20) !== MAGIC) continue; + + const rootSector = vd.readUInt32LE(20); + const rootSize = vd.readUInt32LE(24); + const rootOffset = partitionOffset + rootSector * SECTOR_SIZE; + const dir = await readBytes(file, rootOffset, rootSize); + + let pos = 0; + while (pos < dir.length) + { + if (dir[pos] === 0xFF) break; + if (pos + 14 > dir.length) break; + + const nameLen = dir[pos + 13]; + if (nameLen === 0 || nameLen === 0xFF) break; + if (pos + 14 + nameLen > dir.length) break; + + const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen); + const fileSector = dir.readUInt32LE(pos + 4); + + if (name.toLowerCase() === "default.xex") + { + const xexBase = partitionOffset + fileSector * SECTOR_SIZE; + // Reader that translates relative XEX offsets to absolute ISO offsets + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, xexBase + offset, length) + ); + } + + const entryLen = 14 + nameLen; + pos += (entryLen + 3) & ~3; + } + } + + throw new Error("Not a valid Xbox 360 ISO or default.xex not found"); +} + +async function titleIdFromFolder (folderPath: string): Promise +{ + return titleIdFromXexFile(join(folderPath, "default.xex")); +} + +type XeniaRomType = "iso" | "xex" | "folder"; + +function detectRomType (romPath: string): XeniaRomType +{ + const lower = romPath.toLowerCase(); + if (lower.endsWith(".iso")) return "iso"; + if (lower.endsWith(".xex")) return "xex"; + return "folder"; // extracted game folder containing default.xex +} + +async function getTitleId (romPath: string): Promise +{ + switch (detectRomType(romPath)) + { + case "iso": return titleIdFromIso(romPath); + case "xex": return titleIdFromXexFile(romPath); + case "folder": return titleIdFromFolder(romPath); + } +} + +export async function getXeniaSavePaths ( + romPath: string, + xeniaDir: string +): Promise +{ + const titleId = await getTitleId(romPath); + return join(xeniaDir, titleId); +}; \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 7257559..6a021da 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { ensureDir } from "fs-extra"; import toml, { TomlTable } from 'smol-toml'; import fs from 'node:fs/promises'; +import { getXeniaSavePaths } from "./utils"; export default class XENIAIntegration implements PluginType { @@ -17,7 +18,8 @@ export default class XENIAIntegration implements PluginType await Bun.write(path.join(ctx.path, "portable.txt"), ""); } - async handleLaunch (ctx: Parameters['0']) + async handleLaunch (ctx: Parameters['0']): + ReturnType { const args: string[] = []; @@ -28,6 +30,13 @@ export default class XENIAIntegration implements PluginType const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + if (!ctx.dryRun) { await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); @@ -47,28 +56,30 @@ export default class XENIAIntegration implements PluginType configFile.Display.fullscreen = config.get('launchInFullscreen'); configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; - await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!)); + const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + await ensureDir(savesPath); configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); await Bun.write(configPath, toml.stringify(configFile)); + + let finalSavesPath: string | undefined = undefined; + if (ctx.autoValidCommand.metadata.romPath) + { + finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + } + + return { args, savesPath: finalSavesPath }; }; - args.push(`--config`, configPath); - - if (config.get('launchInFullscreen')) - { - args.push(`--fullscreen`); - } - - return args; + return { args }; } handleEmulatorLaunchSupport (ctx: Parameters['0']): ReturnType { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } load (ctx: PluginContextType) @@ -78,5 +89,14 @@ export default class XENIAIntegration implements PluginType ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + { + if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + { + const files = await fs.readdir(saveFolderPath, { recursive: true }); + validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + } + }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 2b3621c..46c64db 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,8 +2,8 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; -import { config } from "@/bun/api/app"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; @@ -11,6 +11,7 @@ import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; +import { validateGameSource } from "@/bun/api/games/services/statusService"; export default class RommIntegration implements PluginType { @@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType missing: rom.missing_from_fs, genres: rom.metadatum.genres, companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined + release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, + imdb_id: rom.igdb_id ?? undefined, + ra_id: rom.ra_id ?? undefined }; const userData = await getCurrentUserApiUsersMeGet(); @@ -371,12 +374,143 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => { - if (source !== 'romm') return false; + if (source !== 'romm') return; + if (saveFolderPath) + { + setProgress(0, "saves"); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const existingSaveSync = await fs.stat(savePath); + const updatedAtTime = new Date(slot.latest.updated_at).getTime(); + + if (existingSaveSync.mtimeMs > updatedAtTime) + { + console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); + // Newer file + continue; + } else if (updatedAtTime === existingSaveSync.mtimeMs) + { + //TODO: do checksum comparison when that works on romm + console.log("Same save file", savePath); + continue; + } + } + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + if (!saveResponse.ok) + { + console.error("Error downloading save", saveResponse.statusText); + break; + } + await Bun.write(savePath, saveResponse); + console.log("Loaded", savePath); + setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + } + } + + setProgress(1, "saves"); + await Bun.sleep(1000); + } + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + { + if (source !== 'romm') return; + + const sourceValidation = await validateGameSource(source, id); + if (!sourceValidation.valid) + { + console.warn("Invalid Source", sourceValidation.reason, "Skipping updates"); + return; + } + + const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else if (saveFolderPath) + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const stat = await fs.stat(savePath); + if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime()) + { + const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (!finalSavePaths.some(f => f.subPath === subPath)) + { + // Add newer files to the list, maybe they were changed offscreen. + finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + } + } + } + } + } + + if (finalSavePaths.length > 0) + { + console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + + await Promise.all(finalSavePaths.map(async f => + { + const absolutePath = path.join(f.cwd, f.subPath); + if (!await fs.exists(absolutePath)) return; + const stat = await fs.stat(absolutePath); + if (stat.isDirectory()) return; + const data: FormData = new FormData(); + data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + + const url = new URL(`${config.get('rommAddress')}/api/saves`); + url.searchParams.set('rom_id', id); + url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('autocleanup', "true"); + url.searchParams.set('autocleanup_limit', "2"); + if (command.emulator) + url.searchParams.set('emulator', command.emulator); + url.searchParams.set('overwrite', "true"); + + const auth = await this.getAuthToken(); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const response = await fetch(url, { + body: data, + method: "POST", + headers + }); + if (!response.ok) console.error(response.statusText); + })); + + events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" }); + } + const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); if (resp.error) console.error(resp.error); - return resp.response.ok; + events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" }); }); ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index f331bb6..308f217 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -223,14 +223,28 @@ export class JobContext, TData, TState extends str } } catch (error) { - if (error !== 'cancel') + try { - console.error(error); + if (error instanceof Event) + { + if (error.target instanceof AbortSignal) + { + + } else + { + console.error(error); + } + } else + { + console.error(error); + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + } + } finally + { + this.m_promise.resolve(undefined); } - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; - this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 37edb26..a069069 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,7 +1,15 @@ import { RPC_URL } from "@/shared/constants"; +import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; + +const customIconMap = { + save: , + upload: , + clock: +}; + export default function Notifications (data: {}) { useEffect(() => @@ -10,7 +18,13 @@ export default function Notifications (data: {}) es.addEventListener('notification', (e) => { const notification = JSON.parse(e.data) as FrontendNotification; - const options: ToastOptions = { removeDelay: notification.duration }; + const options: ToastOptions = { + removeDelay: notification.duration, + style: { + borderRadius: "64px" + } + }; + if (notification.icon) options.icon = customIconMap[notification.icon]; if (notification.type === 'error') { toast.error(notification.message, options); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index 5523f52..c3b1fcc 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -2,16 +2,16 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { RPC_URL } from "@/shared/constants"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react"; import prettyBytes from "pretty-bytes"; import { JSX } from "react"; import ActionButtons from "./ActionButtons"; +import prettyMilliseconds from 'pretty-ms'; - -export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; }) +export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { return ( -
    +
    {data.icon} {data.children}
    @@ -62,15 +62,14 @@ export default function Details (data: { }
    -
    - } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} +
    + } >{data.game?.last_played ? `${prettyMilliseconds(new Date().getTime() - new Date(data.game.last_played).getTime(), { compact: true, verbose: true })} ago` : "Never"} {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && -
    -
    - {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)} -
    +
    + {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}
    } :
    } >{data.game?.platform_display_name ??
    }
    + {data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />} } > diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index b5a730e..48ba808 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) => window.addEventListener('message', (e) => { - switch (e.data.type) + const data = e.data as EmulatorJsMessage; + switch (data.type) { case 'pause': - if (e.data.data === true) + if (data.paused) { window.EJS_emulator.pause(); } else @@ -24,14 +25,51 @@ window.addEventListener('message', (e) => case 'restart': window.EJS_emulator.elements.bottomBar.restart[0].click(); break; + case 'requestSave': + window.EJS_emulator.elements.bottomBar.saveSavFiles[0].click(); + break; } }); +function postMessage (m: EmulatorJsMessage) +{ + window.parent.postMessage( + m, + "*" + ); +} + +export function loadEmulatorJSSave (save: Uint8Array) +{ + const FS = window.EJS_emulator.gameManager.FS; + const path = window.EJS_emulator.gameManager.getSaveFilePath(); + const paths = path.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) + { + if (paths[i] === "") continue; + cp += "/" + paths[i]; + if (!FS.analyzePath(cp).exists) FS.mkdir(cp); + } + if (FS.analyzePath(path).exists) FS.unlink(path); + FS.writeFile(path, save); + window.EJS_emulator.gameManager.loadSaveFiles(); +} + window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; +window.EJS_onGameStart = async () => +{ + const savesResponse = await fetch(`${RPC_URL(__HOST__)}/api/romm/emulatorjs/load?filePath=${encodeURIComponent(window.EJS_emulator.gameManager.getSaveFilePath())}`); + if (savesResponse.ok) + { + loadEmulatorJSSave(new Uint8Array(await savesResponse.arrayBuffer())); + postMessage({ type: "loaded" }); + } +}; // For core downloads, it either redirects to CDN or uses local if downloaded window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`; window.EJS_Buttons = { @@ -40,10 +78,8 @@ window.EJS_Buttons = { displayName: "Exit", callback: () => { - window.parent.postMessage( - { type: "exit" }, - "*" - ); + const saveFile = window.EJS_emulator.gameManager.getSaveFile(false); + postMessage({ type: "exit", save: saveFile ? new File([saveFile], window.EJS_emulator.gameManager.getSaveFilePath()) : undefined }); } } }; @@ -58,7 +94,18 @@ const moduleUrls = import.meta.glob import: 'default', }); +function handeSave (ctx: { save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) +{ + window.parent.postMessage({ type: 'save', save: new File([ctx.save], window.EJS_emulator.gameManager.getSaveFilePath()) }); +} + // emulatorjs expects basenames instead of paths for some reason window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()]))); +window.EJS_onSaveUpdate = (ctx: { hash: string, save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) => handeSave(ctx); +window.EJS_onSaveSave = (ctx: { + save: ArrayBuffer; + screenshot: ArrayBuffer; + format: string; +}) => handeSave(ctx); await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/emulatorjs/types.d.ts b/src/mainview/emulatorjs/types.d.ts index 11b8f1f..4021f5d 100644 --- a/src/mainview/emulatorjs/types.d.ts +++ b/src/mainview/emulatorjs/types.d.ts @@ -14,6 +14,7 @@ export declare global EJS_cheats: string[][], EJS_fullscreenOnLoaded: boolean, EJS_startOnLoaded: boolean, + EJS_onGameStart, EJS_core: string, EJS_lightgun: boolean, EJS_biosUrl: string, @@ -56,7 +57,9 @@ export declare global EJS_browserMode, EJS_shaders, EJS_fixedSaveInterval, + EJS_onSaveUpdate, EJS_disableAutoUnload, EJS_disableBatchBootup; + EJS_onSaveSave; } } \ No newline at end of file diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index df6ec54..e1704c3 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -5,20 +5,24 @@ import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; -import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; +import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; +import { FloatingShortcuts } from '../components/Shortcuts'; import { useEventListener } from 'usehooks-ts'; import useActiveControl from '../scripts/gamepads'; import { twMerge } from 'tailwind-merge'; import { HeaderAccounts, HeaderStatusBar } from '../components/Header'; import { RoundButton } from '../components/RoundButton'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; +import toast from 'react-hot-toast'; +import { getErrorMessage } from 'react-error-boundary'; export const Route = createFileRoute('/embedded/$source/$id')({ component: RouteComponent, staticData: { - enterSound: 'launch' + enterSound: 'launch', + missNavSound: false }, loader: async (ctx) => { @@ -45,7 +49,7 @@ function OverlayButton (data: { function Overlay (data: { open: boolean; - iframeRef: RefObject; + postMessage: (m: EmulatorJsMessage) => void; close: () => void; goBack: () => void; }) @@ -64,7 +68,6 @@ function Overlay (data: { }, [data.open]); const { isPointer } = useActiveControl(); - const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value }); return
    @@ -78,7 +81,7 @@ function Overlay (data: { { data.close(); - handleEvent('restart'); + data.postMessage({ type: 'restart' }); }} > @@ -132,6 +135,7 @@ function RouteComponent () }); const iframeRef = useRef(null); const [overlayOpen, setOverlayOpen] = useState(false); + const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m); const { source, id } = Route.useParams(); function HandleGoBack () @@ -147,9 +151,23 @@ function RouteComponent () useEventListener('message', e => { - if (e.data.type === 'exit') + const data = e.data as EmulatorJsMessage; + switch (data.type) { - HandleGoBack(); + case "exit": + rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save }); + HandleGoBack(); + break; + case "loaded": + toast.success("Save Loaded", { icon: }); + break; + case "save": + rommApi.api.romm.emulatorjs.save.put({ save: data.save }).then(r => + { + if (r.error) toast.error(getErrorMessage(r.error.value) ?? "Error While Saving"); + else toast.success("Save Backed Up"); + }); + break; } }); @@ -173,11 +191,11 @@ function RouteComponent () const setPaused = (paused: boolean) => { - if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true }); + if (paused) postMessage({ type: 'pause', paused: true }); else { // we want to prevent input from closing the overlay spilling - setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100); + setTimeout(() => postMessage({ type: 'pause', paused: false }), 100); } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); @@ -191,7 +209,7 @@ function RouteComponent ()
    - +
    diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index ab8c857..f77fd9d 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -104,6 +104,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); + stats.push({ label: "Integrations", content: Array.from(integrations) }); } return ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 63f0907..bba950b 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -5,6 +5,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; import { useJobStatus } from '../scripts/utils'; +import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -13,6 +14,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({ }, }); +const stateLookup: Record = { + saves: "Syncing Saves" +}; + function RouteComponent () { const router = useRouter(); @@ -27,12 +32,18 @@ function RouteComponent () } } + const progressRef = useRef(null); const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { data } = useJobStatus('launch-game', { + const { data, state } = useJobStatus('launch-game', { + onProgress (process, data) + { + if (progressRef.current) + progressRef.current.value = process; + }, onEnded (data) { HandleGoBack(); @@ -41,14 +52,19 @@ function RouteComponent () { HandleGoBack(); }, - }); + }, [progressRef.current, HandleGoBack]); useBlocker({ shouldBlockFn: () => !!data }); return
    -

    Launching {data?.name} ...

    + {!!state && !!stateLookup[state] ? + <> +

    Launching {data?.name} ...

    + + : +

    Launching {data?.name} ...

    }
    ; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 889ee00..ad65539 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -10,7 +10,7 @@ import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; +import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Save, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; @@ -283,6 +283,9 @@ function TitleArea (data: { {data.emulator && data.emulator.integrations.length > 0 &&
    } + {data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) &&
    +
    +
    }
    @@ -319,6 +322,14 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; })
    ; } +const capabilityIconMap: Record = { + saves: , + fullscreen: , + resolution: , + config: , + batch: +}; + export function RouteComponent () { const { id } = Route.useParams(); @@ -366,7 +377,9 @@ export function RouteComponent ()
    {i.id}
    -
    {`${i.capabilities?.join(", ")}`}
    +
    + {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} +
    ; })}
    diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts index bbf8712..743b4ea 100644 --- a/src/mainview/scripts/audio/audio.ts +++ b/src/mainview/scripts/audio/audio.ts @@ -28,6 +28,7 @@ declare module '@tanstack/react-router' { enterSound?: keyof typeof soundMap | null; enterHaptic?: keyof typeof hapticMap | null; goBackSound?: keyof typeof soundMap | null; + missNavSound?: boolean; } } diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 15d48a6..251f000 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -3,6 +3,7 @@ import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; import { getLocalSetting, mobileCheck } from "./utils"; import { oneShot } from "./audio/audio"; +import { Router } from "@/mainview"; let loopStarted = false; let isTouching = false; @@ -108,7 +109,13 @@ function throttleNav (key: string, dir: string, event: Event) const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); if (currentFocusKey === getCurrentFocusKey()) - oneShot('invalidNavigation'); + { + const routes = Router.matchRoutes(Router.history.location.pathname); + if (!routes.some(r => r.staticData.missNavSound === false)) + { + oneShot('invalidNavigation'); + } + } throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index e84b7b7..428be6e 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,5 +1,5 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { RefObject, useEffect, useRef, useState } from "react"; +import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; @@ -272,7 +272,8 @@ export function useJobStatus, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; - } + }, + deps?: DependencyList ) { type Response = JobResponse; @@ -325,7 +326,7 @@ export function useJobStatus Date: Fri, 10 Apr 2026 02:00:11 +0300 Subject: [PATCH 11/38] feat: Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID Fixes #2 --- scripts/dev.ts | 10 +-- src/bun/api/games/games.ts | 11 ++- src/bun/api/games/services/statusService.ts | 51 +++++++++---- src/bun/api/hooks/games.ts | 5 ++ .../com.simeonradivoev.gameflow.romm/romm.ts | 11 ++- .../components/game/ActionButtons.tsx | 71 ++++++++++++------- src/mainview/components/game/Details.tsx | 16 ++++- src/mainview/routes/game/$source.$id.tsx | 3 + src/mainview/scripts/queries/romm.ts | 17 ++++- 9 files changed, 143 insertions(+), 52 deletions(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index ef3ad70..b7c07f5 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -26,15 +26,7 @@ function spawnServer () killSignal: 'SIGUSR1', onExit (subprocess, exitCode, signalCode) { - if (exitCode === 1 && retries <= 3) - { - server = spawnServer(); - retries++; - } else - { - process.exit(); - } - + process.exit(); } }); const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) }); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 531dc93..6d4f6b2 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; +import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; @@ -412,6 +412,15 @@ export default new Elysia() params: z.object({ id: z.string(), source: z.string() }), response: z.any() }) + .get('/game/:source/:id/validate', async ({ params: { id, source } }) => + { + const valid = await validateGameSource(source, id); + return { valid: valid.valid, reason: valid.reason }; + }) + .post('/game/:source/:id/fix_source', async ({ params: { id, source } }) => + { + return fixSource(source, id); + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 97f0d8a..0255d7b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -44,26 +44,53 @@ export async function getLocalGame (source: string, id: string) return localGame; } -export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }> +export async function fixSource (source: string, id: string) +{ + const valid = await validateGameSource(source, id); + if (!valid.valid) + { + if (!valid.localGame) throw new Error("No Local Game"); + if (!valid.localGame.source) throw new Error("No Valid Source"); + + const foundGame = await plugins.hooks.games.searchGame.promise({ + igdb_id: valid.localGame.igdb_id ?? undefined, + ra_id: valid.localGame.ra_id ?? undefined, + source: valid.localGame.source + }); + + if (foundGame) + { + await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id)); + return true; + } else + { + throw new Error("Could not find Source Game"); + } + } else + { + throw new Error("Game Source Already Valid"); + } +} + +export async function validateGameSource (source: string, id: string): Promise<{ + valid: boolean, + localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; }, + reason?: string; +}> { const localGame = await getLocalGame(source, id); - if (!localGame) throw new Error("Could not find local game"); + if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); - if (!sourceGame) return { valid: false, reason: "Source Missing" }; - if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined)) + if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; + if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { - return { valid: false, reason: "IGDB Miss Match" }; - } - - if (sourceGame.ra_id !== (localGame.ra_id ?? undefined)) - { - return { valid: false, reason: "RA Miss Match" }; + return { valid: false, reason: "Metadata Missmatch", localGame }; } } - return { valid: true }; + return { valid: true, localGame }; } export async function updateLocalLastPlayed (id: number) @@ -174,7 +201,7 @@ export default function buildStatusResponse () }, async open (ws) { - sendLatests(); + sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) })); const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index 38016aa..b53a00f 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -43,6 +43,11 @@ export class GameHooks localGame?: FrontEndGameTypeDetailed; id: string; }], FrontEndGameTypeDetailed | undefined>(['ctx']); + searchGame = new AsyncSeriesBailHook<[ctx: { + source: string; + igdb_id?: number; + ra_id?: number; + }], FrontEndGameTypeDetailed | undefined>(['ctx']); /** Get download file URLs * @param ctx.checksum Check if file already exists using checksums */ diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 46c64db..8ec3a62 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,7 +2,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -557,5 +557,14 @@ export default class RommIntegration implements PluginType const platforms = await this.getAllRommPlatforms(); return platforms.find(p => p.id === Number(id)); }); + + ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => + { + if (source !== 'romm') return; + const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); + if (roms.error) throw roms.error; + if (!roms.data) return; + return this.convertRomToFrontendDetailed(roms.data); + }); } } \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index d931fa6..8730779 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -1,10 +1,10 @@ -import { deleteGameMutation, gameInvalidationQuery } from "@/mainview/scripts/queries/romm"; +import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; -import { Settings, Trash, Trophy } from "lucide-react"; +import { Hammer, Settings, Trash, Trophy } from "lucide-react"; import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; @@ -33,6 +33,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); + const fixMutation = useMutation({ + ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context) + { + if (onMutateResult) toast.success("Updated Source"); + context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back()); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Trying To Fix"); + } + }); + const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); const router = useRouter(); const deleteMutation = useMutation({ @@ -47,32 +59,41 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, } }); - useBlocker({ shouldBlockFn: () => deleteMutation.isPending }); + useBlocker({ + shouldBlockFn: () => + { + return deleteMutation.isPending || fixMutation.isPending; + } + }); const contextOptions: DialogEntry[] = []; if (data.game?.local) { - if (deleteMutation.isPending) - { - contextOptions.push({ - id: 'delete', - icon: , - content: "Deleting", - type: 'error' - }); - } else - { - contextOptions.push({ - id: 'delete', - action: () => - { - deleteMutation.mutate(); - }, - icon: , - content: "Delete", - type: 'error' - }); - } + contextOptions.push({ + id: 'delete', + action: () => + { + deleteMutation.mutate(); + }, + icon: deleteMutation.isPending ? : , + content: deleteMutation.isPending ? "Deleting" : "Delete", + type: 'error' + }); + } + + if (!validation?.valid) + { + contextOptions.push({ + id: "fix_source", + action (ctx) + { + if (data.game) + fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }); + }, + icon: fixMutation.isPending ? : , + content: "Try Fix Source", + type: "warning" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c3b1fcc..570e670 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -2,11 +2,13 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { RPC_URL } from "@/shared/constants"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react"; +import { Clock, CloudBackup, CloudDownload, CloudUpload, Gamepad2, HardDrive, Store, TriangleAlert } from "lucide-react"; import prettyBytes from "pretty-bytes"; import { JSX } from "react"; import ActionButtons from "./ActionButtons"; import prettyMilliseconds from 'pretty-ms'; +import { useQuery } from "@tanstack/react-query"; +import { validateSourceQuery } from "@/mainview/scripts/queries/romm"; export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { @@ -18,6 +20,12 @@ export function DetailElement (data: { icon: JSX.Element; tooltip?: string | nul ); } +const sourceIconMap: Record = { + store: , + local: , + romm: +}; + export default function Details (data: { game?: FrontEndGameTypeDetailed, source: string, @@ -32,6 +40,8 @@ export default function Details (data: { forceFocus: true }); + const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); + const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; if (platformCoverImg) platformCoverImg.searchParams.set("width", "64"); @@ -70,8 +80,8 @@ export default function Details (data: {
    } :
    } >{data.game?.platform_display_name ??
    }
    {data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />} - + : } > {data.game?.source ?? data.game?.id.source} {data.game?.local && local} diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index f77fd9d..42d23f1 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -23,6 +23,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; +import { stat } from "node:fs"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -104,6 +105,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + if (data.game.source) + stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); stats.push({ label: "Integrations", content: Array.from(integrations) }); } diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 4ecf6ca..3f73bcf 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,6 @@ import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { rommApi, settingsApi } from "../clientApi"; -import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; +import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -155,4 +155,19 @@ export const gameInvalidationQuery = (source: string, id: string): QueryFilters if (query.queryKey.includes(source) && query.queryKey.includes(id)) return true; return false; }, +}); +export const validateSourceQuery = (source: string, id: string) => queryOptions({ + queryKey: ["game", source, id, "validate"], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).validate.get(); + return data; + } +}); +export const fixSourceMutation = mutationOptions({ + mutationKey: ['game', "fix_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).fix_source.post(); + if (error) throw error; + return data; + } }); \ No newline at end of file From 444d8c4c278c6032b37f44a884cb6d7bf0b54c85 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 12 Apr 2026 22:19:24 +0300 Subject: [PATCH 12/38] feat: Implemented filtering and searching --- src/bun/api/games/games.ts | 140 +++++++++++++---- src/bun/api/games/services/statusService.ts | 17 +- src/bun/api/games/services/utils.ts | 68 ++++---- src/bun/api/hooks/games.ts | 4 + src/bun/api/jobs/launch-game-job.ts | 2 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 63 ++++++-- src/bun/api/schema/app.ts | 10 +- src/bun/api/store/store.ts | 20 ++- src/mainview/components/CardElement.tsx | 17 +- src/mainview/components/CardList.tsx | 26 ++-- src/mainview/components/CollectionList.tsx | 1 - src/mainview/components/CollectionsDetail.tsx | 67 ++++---- src/mainview/components/Constants.tsx | 7 + src/mainview/components/ContextDialog.tsx | 17 +- src/mainview/components/GameList.tsx | 18 ++- src/mainview/components/Header.tsx | 8 +- src/mainview/components/HeaderSearchField.tsx | 102 ++++++++++++ src/mainview/components/LoadMoreButton.tsx | 6 +- src/mainview/components/PlatformsList.tsx | 6 +- src/mainview/components/Screenshots.tsx | 6 +- src/mainview/components/SelectMenu.tsx | 3 +- src/mainview/components/SideFilters.tsx | 147 ++++++++++++++++++ src/mainview/components/StatList.tsx | 2 +- src/mainview/components/game/ActionButton.tsx | 7 +- src/mainview/components/game/Details.tsx | 9 +- src/mainview/components/options/Button.tsx | 8 +- .../components/options/PathSettingsOption.tsx | 2 +- .../components/options/SettingsAppForm.tsx | 2 +- .../components/options/SettingsOption.tsx | 2 +- .../components/store/EmulatorsSection.tsx | 6 +- .../components/store/StoreEmulatorCard.tsx | 4 +- src/mainview/routes/__root.tsx | 13 +- .../routes/collection.$source.$id.tsx | 16 +- src/mainview/routes/game/$source.$id.tsx | 13 +- src/mainview/routes/games.tsx | 25 ++- src/mainview/routes/index.tsx | 18 ++- src/mainview/routes/platform.$source.$id.tsx | 12 +- src/mainview/routes/settings/emulators.tsx | 2 +- src/mainview/routes/settings/interface.tsx | 6 + src/mainview/routes/settings/plugins.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 34 ---- src/mainview/routes/store/tab/emulators.tsx | 6 +- src/mainview/routes/store/tab/games.tsx | 87 ++++++++--- src/mainview/routes/store/tab/route.tsx | 21 ++- src/mainview/scripts/queries/store.ts | 13 +- src/mainview/scripts/shortcuts.ts | 4 +- src/mainview/types.d.ts | 8 +- src/shared/constants.ts | 14 +- src/shared/types..d.ts | 40 ++++- 49 files changed, 841 insertions(+), 290 deletions(-) create mode 100644 src/mainview/components/Constants.tsx create mode 100644 src/mainview/components/HeaderSearchField.tsx create mode 100644 src/mainview/components/SideFilters.tsx diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 6d4f6b2..8e489cf 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; @@ -20,6 +20,7 @@ import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmula import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; +import { cores } from "../emulatorjs/emulatorjs"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -134,12 +135,24 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; if (query.source === 'store') { const shuffledGames = await getShuffledStoreGames(); set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) .map(async (e) => { @@ -185,6 +198,11 @@ export default new Elysia() } } + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + if (query.source) { where.push(eq(schema.games.source, query.source)); @@ -218,7 +236,7 @@ export default new Elysia() { // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); games.push(...remoteGames.map(g => { if (localGameExistsPredicate(g)) @@ -233,37 +251,74 @@ export default new Elysia() } else { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => + games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => + { + if (query.genres && query.genres.length > 0) + { + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + } + + return true; + }).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => + if (query.localOnly !== true) { - if (localGameExistsPredicate(g)) + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { - return false; - } + if (localGameExistsPredicate(g)) + { + return false; + } - if (g.igdb_id) + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; + })); + } else + { + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); + } + + localGames.map(g => + { + const metadata: any = g.metadata; + if (metadata.genres && Array.isArray(metadata.genres)) { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); + metadata.genres.forEach((g: string) => filterSets.genres.add(g)); } - - if (g.ra_id) + if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); + metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); } - - return true; - })); + if (metadata.companies && Array.isArray(metadata.companies)) + { + metadata.companies.forEach((g: string) => filterSets.companies.add(g)); + } + if (metadata.player_count) + { + filterSets.player_counts.add(metadata.player_count); + } + }); } } @@ -280,11 +335,22 @@ export default new Elysia() case 'name': games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); break; + case "release": + games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0)); + break; } } - return { games }; + const filterLists: FrontEndFilterLists = { + age_ratings: Array.from(filterSets.age_ratings), + player_counts: Array.from(filterSets.player_counts), + languages: Array.from(filterSets.languages), + companies: Array.from(filterSets.companies), + genres: Array.from(filterSets.genres) + }; + + return { games, filters: filterLists }; }, { query: GameListFilterSchema, }) @@ -341,8 +407,22 @@ export default new Elysia() return { name: 'EMULATORJS', validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], - logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, - systems: [], + logo: 'https://emulatorjs.org/logo/EmulatorJS.png', + systems: await Promise.all(Object.keys(cores).map(async c => + { + const mapping = await emulatorsDb.query.systemMappings.findFirst({ + where (fields, operators) + { + return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c)); + }, columns: { sourceSlug: true } + }); + const system: EmulatorSystem = { + id: c, + name: c, + iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg` + }; + return system; + })), gameCount: 0, integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; @@ -536,8 +616,8 @@ export default new Elysia() const sourceData = await getSourceGameDetailed(source, id); if (!sourceData) return status("Not Found"); - const sourceCompaniesSet = new Set(sourceData.companies); - const sourceGenresSet = new Set(sourceData.genres); + const sourceCompaniesSet = new Set(sourceData.metadata.companies); + const sourceGenresSet = new Set(sourceData.metadata.genres); const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; @@ -550,7 +630,7 @@ export default new Elysia() const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); - games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); + games.push(...localGames.map(g => convertLocalToFrontend(g))); const shuffledGames = await getShuffledStoreGames(); const storeGames = await Promise.all(shuffledGames @@ -559,7 +639,7 @@ export default new Elysia() const system = path.dirname(g.path); const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`${system}@${id}`)) + if (localGamesSourceSet.has(`store@${system}@${id}`)) return false; if (esSystem) diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 0255d7b..53ec3de 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -60,7 +60,19 @@ export async function fixSource (source: string, id: string) if (foundGame) { - await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id)); + await db.update(appSchema.games).set({ + source: foundGame.id.source, + source_id: foundGame.id.id, + metadata: { + age_ratings: foundGame.metadata.age_ratings, + genres: foundGame.metadata.genres, + player_count: foundGame.metadata.player_count ?? undefined, + companies: foundGame.metadata.companies, + game_modes: foundGame.metadata.game_modes, + average_rating: foundGame.metadata.average_rating ?? undefined, + first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, valid.localGame.id)); return true; } else { @@ -82,6 +94,9 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { + // Store should be immutable + if (localGame.source === 'store') return { valid: true, localGame }; + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index cb53377..955d1e7 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -32,7 +32,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { }) { const game: FrontEndGameType = { - platform_display_name: g.platform?.name ?? "Local", + platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, path_cover: `/api/romm/game/local/${g.id}/cover`, @@ -45,17 +45,24 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { slug: g.slug, name: g.name, platform_id: g.platform_id, - platform_slug: g.platform?.slug ?? null + platform_slug: g.platform?.slug ?? null, + metadata: { + first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null + } }; return game; } -export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { - platform?: typeof schema.platforms.$inferSelect | null; +export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { + platform?: { name: string | null, slug: string | null; } | null; screenshotIds?: number[]; }) { + + const exists = await checkInstalled(g.path_fs); + const fileSize = await calculateSize(g.path_fs); + const game: FrontEndGameTypeDetailed = { platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, @@ -72,9 +79,18 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel platform_id: g.platform_id, platform_slug: g.platform?.slug ?? null, summary: g.summary, - fs_size_bytes: 0, - missing: false, - local: true + fs_size_bytes: fileSize, + missing: !exists, + local: true, + metadata: { + genres: g.metadata.genres ?? [], + companies: g.metadata.companies ?? [], + game_modes: g.metadata.game_modes ?? [], + age_ratings: g.metadata.age_ratings ?? [], + player_count: g.metadata.player_count ?? null, + average_rating: g.metadata.average_rating ?? null, + first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null + } }; return game; @@ -107,7 +123,10 @@ export async function convertStoreToFrontend (system: string, id: string, storeG name: storeGame.title, platform_id: null, platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] + paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: null + } }; return game; @@ -131,6 +150,15 @@ export async function convertStoreToFrontendDetailed (system: string, id: string fs_size_bytes: size, missing: false, local: false, + metadata: { + genres: storeGame.tags, + companies: [], + game_modes: [], + age_ratings: [], + player_count: "", + average_rating: null, + first_release_date: null + } }; return detailed; @@ -148,29 +176,7 @@ export async function getLocalGameDetailed (match: any) if (localGame) { - const exists = await checkInstalled(localGame.path_fs); - const fileSize = await calculateSize(localGame.path_fs); - const game: FrontEndGameTypeDetailed = { - path_cover: `/api/romm/game/local/${localGame.id}/cover`, - updated_at: localGame.created_at, - id: { id: String(localGame.id), source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, - fs_size_bytes: fileSize ?? null, - paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), - local: true, - missing: !exists, - platform_display_name: localGame.platform?.name, - summary: localGame.summary, - source: localGame.source, - source_id: localGame.source_id, - path_fs: localGame.path_fs, - last_played: localGame.last_played, - slug: localGame.slug, - name: localGame.name, - platform_id: localGame.platform_id, - platform_slug: localGame.platform.slug - }; - return game; + return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) }); } return undefined; diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index b53a00f..f1f4a6a 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -37,6 +37,10 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; + filters: FrontEndFilterSets; + }]>(['ctx']); + fetchFilters = new AsyncSeriesHook<[ctx: { + filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index b60cb76..bcea594 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -168,7 +168,7 @@ export class LaunchGameJob implements IJob = { + added: "created_at", + activity: "created_at", + name: "name", + release: "metadatum.first_release_date" + }; async updateClient () { @@ -49,8 +55,11 @@ export default class RommIntegration implements PluginType const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, - last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, + last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), + metadata: { + first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null, + }, slug: rom.slug, platform_id: rom.platform_id, platform_display_name: rom.platform_display_name, @@ -74,11 +83,17 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - genres: rom.metadatum.genres, - companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined + ra_id: rom.ra_id ?? undefined, + metadata: { + age_ratings: rom.metadatum.age_ratings, + genres: rom.metadatum.genres, + companies: rom.metadatum.companies, + game_modes: rom.metadatum.game_modes, + player_count: rom.metadatum.player_count, + average_rating: rom.metadatum.average_rating, + first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null + } }; const userData = await getCurrentUserApiUsersMeGet(); @@ -119,26 +134,32 @@ export default class RommIntegration implements PluginType load (ctx: PluginContextType) { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - const orderByMap: Record = { - added: "created_at", - activity: "created_at", - name: "name" - }; - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, collection_id: query.collection_id, limit: query.limit, offset: query.offset, - order_by: orderByMap[query.orderBy ?? ''] + order_by: this.orderByMap[query.orderBy ?? ''], + with_filter_values: true, + genres: query.genres, + genres_logic: "all", + age_ratings: query.age_ratings, + search_term: query.search, }, throwOnError: true }); + + rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); + rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); + rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); + rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); + games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -151,6 +172,16 @@ export default class RommIntegration implements PluginType } }); + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + { + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); + rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommFilters.data.companies.forEach(r => filters.companies.add(r)); + rommFilters.data.languages.forEach(r => filters.languages.add(r)); + rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r)); + rommFilters.data.genres.forEach(r => filters.genres.add(r)); + }); + ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { if (service !== 'romm') return; @@ -277,10 +308,10 @@ export default class RommIntegration implements PluginType const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); if (rommPlatform) { - const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); + const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } }); if (rommGames.data) { - games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); + games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) }))); } } } diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index fafa32a..35c9c5a 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -11,7 +11,15 @@ export const games = sqliteTable('games', { path_fs: text("path_fs"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; + }>().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2a1c42e..5145232 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -25,7 +25,22 @@ export const store = new Elysia({ prefix: '/api/store' }) }); const emulatesParsed = await getAllStoreEmulatorPackages(); let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => e.os.includes(process.platform as any)) + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (query.search) + { + const lowerCaseSearch = query.search.toLocaleLowerCase(); + + if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) + { + return true; + } + + return false; + } + return true; + }) .map(async (emulator) => { const systems = await buildStoreFrontendEmulatorSystems(emulator); @@ -77,7 +92,8 @@ export const store = new Elysia({ prefix: '/api/store' }) limit: z.coerce.number().optional(), missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(), - related: z.string().optional() + related: z.string().optional(), + search: z.string().optional() }) }) .get('/games/featured', async () => diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index 885d073..fdbee68 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -18,9 +18,7 @@ export function GameCardSkeleton () ); } -export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void; - -export interface GameCardParams +export interface GameCardParams extends FocusParams { title: string; subtitle: string | JSX.Element; @@ -31,7 +29,6 @@ export interface GameCardParams id: string; badges?: JSX.Element[]; className?: string; - onFocus?: GameCardFocusHandler; onBlur?: (id: string) => void; clickFocuses?: boolean; previewClassName?: string; @@ -39,14 +36,14 @@ export interface GameCardParams export default function CardElement (data: GameCardParams & InteractParams) { - const handleAction = () => + const handleAction = (event?: Event) => { - data.onAction?.(); + data.onAction?.({ event, focusKey }); oneShot('click'); }; - const { ref, focused, focusSelf } = useFocusable({ + const { ref, focused, focusSelf, focusKey } = useFocusable({ focusKey: data.focusKey, - onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), + onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current as any, details), onEnterPress: handleAction, onBlur: () => data.onBlur?.(data.id), }); @@ -63,10 +60,10 @@ export default function CardElement (data: GameCardParams & InteractParams) scrollSnapAlign: isPointer ? "center" : "none" }} onFocus={focusSelf} - onClick={() => + onClick={(e) => { focusSelf(); - handleAction(); + handleAction(e.nativeEvent); }} className={twMerge( "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 2744585..671018f 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -4,12 +4,11 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { GameMeta } from "../../shared/constants"; -import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement"; +import CardElement, { GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; -import { GamepadButtonEvent } from "../scripts/gamepads"; export interface GameMetaExtra extends GameMeta { @@ -26,13 +25,14 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara preview = data.game.previewUrl; } - const handleAction = (e?: Event) => + const handleAction = (ctx: InteractParamsArgs) => { data.game.onSelect?.(); - data.onAction?.(); + data.onAction?.({ event, focusKey: data.game.focusKey }); oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); + + useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); return ( + onFocus={(focusKey, node, details) => { - data.game.onFocus?.(details); - data.onFocus?.(id, node, details); + data.game.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, details); }} onAction={handleAction} preview={preview} @@ -61,16 +61,18 @@ export function CardList (data: { games: GameMetaExtra[]; grid?: boolean; onSelectGame?: (id: string) => void; - onGameFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const { ref, focusKey } = useFocusable({ focusKey: data.id, forceFocus: true, - autoRestoreFocus: true + autoRestoreFocus: true, + focusable: data.games.length > 0, + preferredChildFocusKey: data.focus }); return ( @@ -92,7 +94,7 @@ export function CardList (data: { > {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} + key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)} {data.finalElement} diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 15b8d51..121be98 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,7 +1,6 @@ import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; import { CardList, GameMetaExtra } from "./CardList"; -import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; import { useRouter } from "@tanstack/react-router"; diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 717e986..0b9e0e1 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,44 +1,50 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { StickyHeaderUI } from './Header'; +import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense } from 'react'; +import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react'; +import { JSX, Suspense, useRef, useState } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; -import { GameCardFocusHandler } from './CardElement'; +import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; -import { useRouter } from '@tanstack/react-router'; +import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; +import { RoundButton } from './RoundButton'; +import { ContextList, DialogEntry, useContextDialog } from './ContextDialog'; +import classNames from 'classnames'; +import { sourceIconMap } from './Constants'; +import { stat } from 'fs-extra'; +import { FilterUI } from './Filters'; +import SideFilters from './SideFilters'; export interface CollectionsDetailParams { id?: string; setBackground?: (url: string) => void; filters?: GameListFilterType; - builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, headerTitle?: JSX.Element; + headerChildren?: any; title?: JSX.Element; footer?: JSX.Element; focus?: string; - countHit?: number; + countHint?: number; + headerButtons?: HeaderButton[]; + headerButtonElements?: JSX.Element | JSX.Element[]; } export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const builtData = useQuery({ - queryKey: ['filter', data.id], queryFn: async () => - { - return data.builder?.() ?? { filter: data.filters, title: data.title }; - } - }); + const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); - const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`; + const finalFilter = { ...data.localFilter, ...data.filters }; + const focusKey = `game-list-${data.id}`; const { ref, focusSelf } = useFocusable({ focusKey, preferredChildFocusKey: `${focusKey}-list` @@ -46,9 +52,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); - const handleScroll: GameCardFocusHandler = (cardId, node, details) => + const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => { - const [source, id] = cardId.split('@'); queryClient.prefetchQuery(gameQuery(source, id)); @@ -61,22 +66,27 @@ export function CollectionsDetail (data: CollectionsDetailParams) return (
    - }, { id: "filter", icon: }]} ref={ref} /> -
    -
    - {builtData.data?.filter && data.title} - {(builtData.data?.filter || (!data.filters && !data.builder)) && }> + + {data.headerChildren} + +
    +
    +
    +
    +
    + {finalFilter && data.title} + {}> - + } -
    -
    -
    @@ -85,6 +95,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
    +
    + +
    diff --git a/src/mainview/components/Constants.tsx b/src/mainview/components/Constants.tsx new file mode 100644 index 0000000..f6de5ae --- /dev/null +++ b/src/mainview/components/Constants.tsx @@ -0,0 +1,7 @@ +import { Gamepad2, HardDrive, Store } from "lucide-react"; + +export const sourceIconMap: Record = { + store: , + local: , + romm: +}; \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 6024311..b0acd8f 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -35,7 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class const handleAction = () => { if (data.disabled === true) return; - data.action?.({ close: context.close, focus: focusSelf }); + data.action?.({ close: context.close, focus: focusSelf, selected: data.selected }); oneShot('click'); }; const { ref, focusSelf, focusKey } = useFocusable({ @@ -82,7 +82,7 @@ export interface DialogEntry icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; selected?: boolean; - action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; + action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; selected?: boolean; }) => void; shortcuts?: Shortcut[]; } @@ -102,6 +102,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { setOpen(false); data.onClose?.(); + oneShot('closeContext'); if (newSourceFocusKey) { setFocus(newSourceFocusKey, { instant: true }); @@ -118,7 +119,12 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla return { dialog, open, - setOpen: handleClose + setOpen: handleClose, + setToggle: (focNewSourceFocusKey?: string | undefined) => + { + if (open) handleClose(false, focNewSourceFocusKey); + else handleClose(true, focNewSourceFocusKey); + } }; } @@ -142,7 +148,6 @@ export function ContextDialog (data: { const handleClose = () => { data.close(false); - oneShot('closeContext'); }; useEffect(() => { @@ -161,7 +166,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -169,7 +174,7 @@ export function ContextDialog (data: {
    void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; - onFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; + setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) { - const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime }); + const games = useSuspenseQuery({ ...allGamesQuery(data.filters), queryKey: ['games', data.filters ?? 'all'], staleTime: DefaultRommStaleTime }); const navigator = useNavigate(); const blur = useLocalSetting('backgroundBlur'); const backgroundContext = useContext(AnimatedBackgroundContext); @@ -48,6 +48,11 @@ export function GameList (data: GameListParams) } }; + useEffect(() => + { + data.setFilterValues?.(games.data.filters); + }, [games.data.filters]); + function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -60,9 +65,10 @@ export function GameList (data: GameListParams) type="game" grid={data.grid} className={data.className} - onGameFocus={data.onFocus} + onFocus={data.onFocus} finalElement={data.finalElement} saveChildFocus={data.saveChildFocus} + focus={data.focus} games={games.data?.games .map( (g) => diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index cc17aba..3a2e8dd 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -234,7 +234,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) }); } - return
    + return
    {accounts?.map(a => {!!data.buttons &&
    }
    - {data.buttonElements ?? data.buttons?.map(b => ; className?: string; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; children?: any; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -317,6 +318,7 @@ export function StickyHeaderUI (data: { ref: RefObject; className?: string;
    + {data.children}
    ; } \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx new file mode 100644 index 0000000..3845987 --- /dev/null +++ b/src/mainview/components/HeaderSearchField.tsx @@ -0,0 +1,102 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { Search } from "lucide-react"; +import { RoundButton } from "./RoundButton"; +import { useEventListener } from "usehooks-ts"; + +function SearchInput (data: { + id: string; + autoSearch?: boolean; + search: string | undefined; + compact: boolean | undefined; + onInputFocus: () => void; + setShowInput: (show: boolean) => void; + onSubmit: (search: string | undefined) => void; +} & FocusParams) +{ + const { ref, focusKey } = useFocusable({ + onBlur: () => inputRef.current?.blur(), + onFocus: (l, p, d) => + { + data.onFocus?.(focusKey, ref.current, { ...d, inputRef }); + if (data.autoSearch) inputRef.current?.focus(); + }, + focusKey: data.id, + onEnterPress: () => + { + if (document.activeElement === inputRef.current) + { + if (inputRef.current) + data.onSubmit?.(inputRef.current.value); + } else + { + inputRef.current?.focus(); + } + } + }); + + const inputRef = useRef(null); + const [localSearch, setLocalSearch] = useState(data.search); + + useEffect(() => + { + setLocalSearch(data.search ?? ""); + }, [data.search]); + + useShortcuts(focusKey, () => document.activeElement === inputRef.current ? [{ + label: "Cancel", + button: GamePadButtonCode.B, action (e) + { + inputRef.current?.blur(); + oneShot('returnGeneric'); + }, + }] : [], [inputRef.current, document.activeElement]); + + useEventListener('search' as any, e => + { + data.onSubmit?.(undefined); + }, inputRef as any); + + return ; +} + +export default function HeaderSearchField (data: { + id: string; + autoSearch?: boolean; + search: string | undefined, + onSubmit: (search: string | undefined) => void; + compact?: boolean; +} & FocusParams) +{ + const [showInput, setShowInput] = useState(false); + + const { ref, focusKey, focusSelf } = useFocusable({ + focusKey: data.id, + focusBoundaryDirections: ['left', "right"], + isFocusBoundary: data.compact && showInput + }); + + return
    + + {(!data.compact || showInput) && } + {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index afcd9b7..0a70f93 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -4,9 +4,9 @@ import { useIntersectionObserver } from "usehooks-ts"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); if (data.lastId && focused) setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); }; @@ -18,8 +18,6 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr onEnterPress: handleAction }); - - const { ref: intersct } = useIntersectionObserver({ initialIsIntersecting: true, rootMargin: "20%", diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 48af4a3..039e20a 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,7 +5,6 @@ import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; -import { GameCardFocusHandler } from "./CardElement"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; @@ -13,11 +12,10 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; grid?: boolean; onSelect?: (source: string, id: string) => void; saveChildFocus?: "session" | "local"; -}) +} & FocusParams) { const isMobile = mobileCheck(); const navigate = useNavigate(); @@ -88,7 +86,7 @@ export function PlatformsList (data: { id={data.id} grid={data.grid} className={twMerge('*:aspect-8/10! md:py-12', data.className)} - onGameFocus={data.onFocus} + onFocus={data.onFocus} games={platformsMapped} onSelectGame={(id) => { diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index ae5af90..42d76d3 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -12,9 +12,9 @@ import { twMerge } from "tailwind-merge"; function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { const imageRef = useRef(null); - const { ref, focusSelf } = useFocusable({ + const { ref, focusSelf, focusKey } = useFocusable({ focusKey: `screenshot-${data.index}`, - onEnterPress: () => data.onAction?.(), + onEnterPress: () => data.onAction?.({ focusKey }), onFocus: (e, p, details) => { data.setFocused?.(data.index); @@ -23,7 +23,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n }); 4096; return
    focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" /> -
    data.onAction?.(e.nativeEvent)}>
    +
    data.onAction?.({ event: e.nativeEvent, focusKey })}>
    ; } diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4e68b68..4ad4878 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -9,7 +9,6 @@ import { FOCUS_KEYS } from "../scripts/types"; export default function SelectMenu (data: { rootFocusKey: string; }) { const navigate = useNavigate(); - const routeState = useRouterState(); const matchRoute = useMatchRoute(); const options: DialogEntry[] = [ @@ -85,7 +84,7 @@ export default function SelectMenu (data: { rootFocusKey: string; }) ]; const { dialog, setOpen, open } = useContextDialog('select-menu', { content: , - className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none', + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen', preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') }); useShortcuts(data.rootFocusKey, () => [{ diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx new file mode 100644 index 0000000..117577c --- /dev/null +++ b/src/mainview/components/SideFilters.tsx @@ -0,0 +1,147 @@ +import { GameListFilterType } from "@/shared/constants"; +import { RoundButton } from "./RoundButton"; +import classNames from "classnames"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; +import { sourceIconMap } from "./Constants"; +import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; + +function FilterButton (data: { + id: string, + filters?: GameListFilterType, + tooltip: string, + icon: any; + dialog: { + setToggle: (focNewSourceFocusKey?: string | undefined) => void; + }; + isActive: boolean; +}) +{ + const handleAction = () => data.dialog.setToggle(data.id); + useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); + return
    + + {data.icon} + +
    ; +} + +export default function SideFilters (data: { + id: string, + filters?: GameListFilterType; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, + filterValues: FrontEndFilterLists | undefined; +}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: data.id }); + + const orderByDialog = useContextDialog('order-by-dialog', { + content: }, + { stat: "activity", icon: }, + { stat: "added", icon: }, + { stat: "release", icon: }, + ] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[]) + .map(o => ({ + content: o.stat, + icon: o.icon, + selected: data.localFilter.orderBy === o.stat, + id: `sort-by-${o.stat}`, + type: 'primary', + action (ctx) + { + data.setLocalFilter({ ...data.localFilter, orderBy: o.stat }); + ctx.close(); + }, + }))} />, + preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` + }); + + const sourceFilterDialog = useContextDialog('source-filter-dialog', { + content: (o => ({ + content: o, + icon: sourceIconMap[o], + selected: data.localFilter.source === o, + id: `source-filter-${o}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined }); + else data.setLocalFilter({ ...data.localFilter, source: o }); + ctx.close(); + }, + })).concat({ + content: "Local Only", + icon: , + selected: data.localFilter.localOnly === true, + id: `source-filter-local`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined }); + else data.setLocalFilter({ ...data.localFilter, localOnly: true }); + ctx.close(); + }, + })} />, + preferredChildFocusKey: `source-filter-${data.localFilter.source}` + }); + + const genreFilterDialog = useContextDialog('genre-filter-dialog', { + content: ({ + content: g, + selected: data.localFilter.genres?.includes(g), + id: `genre-filter-${g}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] }); + ctx.close(); + }, + }))} /> + }); + + const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', { + content: ({ + content: a, + selected: data.localFilter.age_ratings?.includes(a), + id: `age-rating-filter-${a}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] }); + ctx.close(); + }, + }))} /> + }); + + return
    + + } /> + 0} icon={} /> + 0} icon={} /> + {!data.filters?.source && + } /> + } + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
    + data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } + {orderByDialog.dialog} + {sourceFilterDialog.dialog} + {genreFilterDialog.dialog} + {ageRatingFilterDialog.dialog} +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index de1c231..bdc9a02 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -37,7 +37,7 @@ export default function StatList (data: { content =
    {s.content.map((c, ci) => {c})}
    ; } else { - content =
    {s.icon}{s.content}
    ; + content =
    {s.icon}{s.content}
    ; } return [
    ; } diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index d9b81f7..47c07c0 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -95,9 +95,9 @@ export function StoreEmulatorCard (data: { >
    } - {data.emulator.validSources.slice(0, 3).map(s => + {data.emulator.validSources.slice(0, 3).map((s, i) => { - return
    + return
    {emulatorStatusIcons[s.type]}
    diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 2687614..fbe2f26 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -6,6 +6,8 @@ import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -32,6 +34,9 @@ function RootComponent () }, [theme]); + const queryDevOptions = useLocalSetting('showQueryDevOptions'); + const routerDevOptions = useLocalSetting('showRouterDevOptions'); + return (
    @@ -39,12 +44,8 @@ function RootComponent () - {/*import.meta.env.DEV && !isMobile && - <> - - - - */} + {queryDevOptions && } + {routerDevOptions && }
    ); } diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 2f62d91..3b73d25 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,10 +6,14 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) + validateSearch: zodValidator(z.object({ + countHint: z.number().optional() + })) }); function RouteComponent () @@ -18,8 +22,16 @@ function RouteComponent () const { countHint } = Route.useSearch(); const { data: collection } = useQuery(getCollectionQuery(source, id)); const animatedBgContext = useContext(AnimatedBackgroundContext); + const [filter, setFilter] = useLocalStorage("collection-filter", {}); return ( - {collection?.name}
    } filters={{ collection_id: Number(id), collection_source: source }} /> + {collection?.name}
    } + filters={{ collection_id: Number(id), collection_source: source }} + /> ); } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 42d23f1..fa54647 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -23,7 +23,6 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; -import { stat } from "node:fs"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -97,12 +96,12 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) { if (data.game.path_fs) stats.push({ label: "Location", content: data.game.path_fs, icon: }); - if (data.game.companies) - stats.push({ label: "Companies", content: data.game.companies }); - if (data.game.genres) - stats.push({ label: 'Genres', content: data.game.genres }); - if (data.game.release_date) - stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.metadata.companies) + stats.push({ label: "Companies", content: data.game.metadata.companies }); + if (data.game.metadata.genres) + stats.push({ label: 'Genres', content: data.game.metadata.genres }); + if (data.game.metadata.first_release_date) + stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); if (data.game.source) diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index d1071fa..3742e83 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -2,15 +2,36 @@ import { createFileRoute } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import HeaderSearchField from '../components/HeaderSearchField'; +import { useEffect, useState } from 'react'; +import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; export const Route = createFileRoute('/games')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + search: z.string().optional() + })) }); function RouteComponent () { const { focus } = Route.useSearch(); + const { search } = Route.useSearch(); + const [filter, setFilter] = useSessionStorage('all-games-filters', {}); - return ; + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); + + return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} + localFilter={filter} + setLocalFilter={setFilter} + focus={focus} + id='all-games' + />; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index eee1194..1e758ad 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -47,6 +47,7 @@ import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; +import HeaderSearchField from "../components/HeaderSearchField"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -232,13 +233,13 @@ function MainMenu () > router.navigate({ to: "/games", state: { eventType: e?.type } })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.event?.type } })} icon={} label="Home" type="secondary" /> } label="News" /> - } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" /> } label="Album" /> } @@ -247,7 +248,7 @@ function MainMenu () { - router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } }); + router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } }); }} icon={} label="Settings" @@ -264,9 +265,9 @@ function CircleIcon (data: { icon?: JSX.Element; } & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); oneShot('click'); }; const { ref, focusKey } = useFocusable({ @@ -313,10 +314,13 @@ export default function ConsoleHomeUI () if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( - { id: "search-header-button", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); + const handleSearch = (search: string | undefined) => + { + router.navigate({ to: '/games', search: { search } }); + }; return ( @@ -334,7 +338,7 @@ export default function ConsoleHomeUI () />
    - + } />
    ("platforms-filters", {}); return (
    } filters={{ platform_id: Number(id), platform_source: source }} /> diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index f9921a3..8f52831 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -148,7 +148,7 @@ function EmulatorPath (data: { id: string; }) autocomplete="off" onChange={(v) => { - setLocalValue(v); + setLocalValue(v as string); setDirty(true); }} value={localValue} diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index 9b930e4..ee0ec8f 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -1,6 +1,7 @@ import { LocalOption } from '@/mainview/components/options/LocalOption'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute } from '@tanstack/react-router'; +import { Terminal } from 'lucide-react'; export const Route = createFileRoute('/settings/interface')({ component: RouteComponent, @@ -22,6 +23,11 @@ function RouteComponent () + {import.meta.env.DEV && <> +
    Dev Settings
    + + + } ; } diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index 7b1a8da..c0c7dab 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -28,7 +28,7 @@ function Plugin (data: {
    {data.plugin.name} ({data.plugin.version})
    } className='flex p-4 bg-base-200 rounded-3xl'> - + data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" /> ; } diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index ad65539..13a1295 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -356,40 +356,6 @@ export function RouteComponent () }); const stats: StatEntry[] = []; - if (emulator) - { - if (emulator.keywords) - stats.push({ label: "Tags", content: emulator.keywords }); - if (emulator.storeDownloadInfo) - stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); - stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.validSources.flatMap(s => [{ - label: "Source", content:
    -
    -
    {emulatorStatusIcons[s.type]}{s.type}
    -
    {s.binPath}
    -
    - {emulator.integrations.some(i => i.source?.type === s.type) &&
    } - {emulator.integrations.filter(i => i.source?.type === s.type).map(i => - { - return
    -
    - -
    {i.id}
    -
    -
    - {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} -
    -
    ; - })} -
    - }])); - if (emulator.bios) - stats.push({ - label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    - }); - - } return ( diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 524e20a..67a5724 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -10,6 +10,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { useSessionStorage } from 'usehooks-ts'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, @@ -18,13 +19,14 @@ export const Route = createFileRoute('/store/tab/emulators')({ function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true }); + const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true }); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7aee585..9f7da33 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -1,27 +1,43 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Gamepad2 } from 'lucide-react'; -import { useContext, useEffect } from 'react'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { Gamepad2, HardDrive } from 'lucide-react'; +import { JSX, useContext, useEffect, useState } from 'react'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import { StoreContext } from '@/mainview/scripts/contexts'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; +import { GameListFilterType, RPC_URL } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import SideFilters from '@/mainview/components/SideFilters'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, - errorComponent: InvalidStoreError + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const navigator = useNavigate(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); + const [filter, setFilter] = useSessionStorage('store-games-filters', {}); + const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter)); + const [filterValues, setFilterValues] = useState(); - const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery); - const storeContext = useContext(StoreContext); + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); useEffect(() => { @@ -38,6 +54,11 @@ function RouteComponent () node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }; + function handleDefaultSelect (g: FrontEndGameType) + { + navigator({ to: '/game/$source/$id', params: { id: g.id.id, source: g.id.source } }); + }; + return <>
    @@ -47,19 +68,8 @@ function RouteComponent () Games
    -
    - {data?.pages.flatMap((page) => ( - page.data.map((g, i) => - { - storeContext.prefetchDetails('game', g.id.source, g.id.id); - handleFocus(k, n, d); - }} key={g.id.id} game={g} index={i} />)) - ) ?? Array.from({ length: 20 }).map((_, i) =>
    -
    -
    -
    -
    )} - + + }} />} games={data?.pages.flatMap((page) => page.data.map((g) => + { + const badges: JSX.Element[] = []; + if (g.id.source === 'local') + { + badges.push(); + } + + const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); + previewUrl.searchParams.delete('ts'); + + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + + return { + id: `${g.id.source}@${g.id.id}`, + focusKey: `${g.id.source}@${g.id.id}`, + title: g.name ?? "", + subtitle: ( +
    + {!!g.path_platform_cover && } +

    {g.platform_display_name}

    +
    + ), + previewUrl: previewUrl.href, + badges: badges, + onSelect: () => handleDefaultSelect(g), + onFocus: (k, n, d) => handleFocus(k, n, d) + } satisfies GameMetaExtra as GameMetaExtra; + }) + ) ?? []} id={'store-games'} /> +
    +
    +
    diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index fd2f76b..f459099 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,6 +1,7 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; +import HeaderSearchField from '@/mainview/components/HeaderSearchField'; import SelectMenu from '@/mainview/components/SelectMenu'; import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; @@ -13,7 +14,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; +import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ @@ -95,6 +97,8 @@ function RouteComponent () emulators: { label: "Emulators", selected: useIsSettings('emulators') }, games: { label: "Games", selected: useIsSettings('games') } }; + const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); + const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); const handleDetails = (type: string, source: string, id: string, focus: string) => { @@ -120,6 +124,19 @@ function RouteComponent () } }; + const handleSearch = (search: string | undefined) => + { + if (filters['home'].selected) + { + setGamesSearch(search); + router.navigate({ to: '/store/tab/games', replace: true, viewTransition: { types: ['slide-up'] } }); + } else + { + setSearch(search); + } + + }; + const isMobile = mobileCheck(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -129,7 +146,7 @@ function RouteComponent ()
    - + } />
    diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 4a881cd..648f9ef 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,11 +1,12 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; +import { GameListFilterType } from "@/shared/constants"; -export const storeEmulatorsQuery = queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => +export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ + queryKey: ['store-emulators', filters], queryFn: async () => { - const { data, error } = await storeApi.api.store.emulators.get(); + const { data, error } = await storeApi.api.store.emulators.get({ query: { search: filters.search } }); if (error) throw new Error(JSON.stringify(error.value)); return data; } @@ -42,14 +43,14 @@ export const storeEmulatorDeleteMutation = mutationOptions({ if (error) throw error; } }); -export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ +export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ initialPageParam: 0, - queryKey: ['store-games'], + queryKey: ['store-games', filter], getNextPageParam: (lastPage, pages) => lastPage.nextPage, queryFn: async (data) => { const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + const { data: games, error } = await rommApi.api.romm.games.get({ query: { ...filter, source: 'store', offset: pageParam * 10, limit: 10 } }); if (error) throw error; return { data: games.games, nextPage: pageParam + 1 }; } diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index c198037..35316b7 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -191,7 +191,7 @@ export function useShortcutContext () return { shortcuts: array }; } -export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList) +export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList) { useEffect(() => { @@ -211,6 +211,6 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps markDirtyThrottled(); }; - }, [...deps, focusKey]); + }, [focusKey, ...deps ?? []]); } \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 34803b0..9100029 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -50,9 +50,15 @@ declare interface FocusParams onFocus?: (focusKey: string, node: HTMLElement, details: Record) => void; } +declare interface InteractParamsArgs +{ + event?: Event, + focusKey?: string; +} + declare interface InteractParams { - onAction?: (e?: Event) => void; + onAction?: (ctx: InteractParamsArgs) => void; } declare interface FilterOption extends FocusParams, InteractParams diff --git a/src/shared/constants.ts b/src/shared/constants.ts index faefce1..5d7b307 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -17,11 +17,10 @@ export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute -export interface GameMeta +export interface GameMeta extends FocusParams { id: string, onSelect?: () => void, - onFocus?: (details: FocusDetails) => void, title: string, subtitle: string | JSX.Element, previewUrl?: string; @@ -46,7 +45,9 @@ export const LocalSettingsSchema = z.object({ theme: z.enum(['dark', 'light', 'auto']).default('auto'), soundEffects: z.boolean().default(true), soundEffectsVolume: z.number().min(0).max(100).default(50), - hapticsEffects: z.boolean().default(true) + hapticsEffects: z.boolean().default(true), + showRouterDevOptions: z.boolean().default(false), + showQueryDevOptions: z.boolean().default(false), }); export const GameListFilterSchema = z.object({ @@ -56,9 +57,14 @@ export const GameListFilterSchema = z.object({ collection_id: z.coerce.number().optional(), collection_source: z.string().optional(), limit: z.coerce.number().optional(), + search: z.string().optional(), offset: z.coerce.number().optional(), source: z.string().optional(), - orderBy: z.literal(['added', 'activity', 'name']).optional() + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), }); export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 077ddd6..ca0fc49 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -57,17 +57,15 @@ declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator } -declare interface FrontEndGameTypeDetailed extends FrontEndGameType +declare interface FrontEndGameTypeDetailed extends Exclude { summary: string | null; fs_size_bytes: number | null; missing: boolean; local: boolean; - genres?: string[]; - companies?: string[]; - release_date?: Date; imdb_id?: number; ra_id?: number; + metadata: FrontEndGameMetadataDetailed, emulators?: FrontEndGameTypeDetailedEmulator[], achievements?: { unlocked: number; @@ -162,6 +160,39 @@ declare interface FrontEndGameTypeWithIds extends FrontEndGameType ra_id: number | null; } +declare interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +declare interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +declare interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +declare interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null, @@ -173,6 +204,7 @@ declare interface FrontEndGameType path_cover: string | null, last_played: Date | null, updated_at: Date, + metadata: FrontEndGameMetadata, slug: string | null, name: string | null, platform_id: number | null, From c09fbd3dc88891227eda2b9f3bd9ac45621c00ea Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 17 Apr 2026 21:21:14 +0300 Subject: [PATCH 13/38] fix: Fixed tests feat: Added RClone integration feat: Implemented plugin settings feat: Updated minimal store version test: Fixed tests feat: Moved store and igdb and es-de to their own plugins --- bun.lock | 27 +- drizzle/0002_flowery_rocket_raccoon.sql | 31 ++ drizzle/meta/0002_snapshot.json | 479 ++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 5 +- scripts/dev.ts | 7 +- .../drizzle/es-de/0000_sparkling_banshee.sql | 34 ++ scripts/drizzle/es-de/meta/0000_snapshot.json | 234 ++++++++ scripts/drizzle/es-de/meta/_journal.json | 13 + scripts/generate-es-de-mapping.ts | 18 +- src/bun/api/app.ts | 8 +- src/bun/api/emulatorjs/emulatorjs.ts | 6 +- src/bun/api/games/games.ts | 464 +++++++--------- src/bun/api/games/platforms.ts | 38 +- .../api/games/services/launchGameService.ts | 401 +------------- src/bun/api/games/services/statusService.ts | 177 +++--- src/bun/api/games/services/utils.ts | 115 +--- src/bun/api/hooks/app.ts | 2 + src/bun/api/hooks/emulators.ts | 2 + src/bun/api/hooks/games.ts | 42 +- src/bun/api/hooks/store.ts | 10 + src/bun/api/jobs/install-job.ts | 85 ++- src/bun/api/jobs/jobs.ts | 2 + src/bun/api/jobs/launch-game-job.ts | 26 +- src/bun/api/jobs/reload-plugins-job.ts | 15 + .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../package.json | 1 + .../dolphin.ts | 16 +- .../package.json | 1 + .../utils.ts | 8 +- .../package.json | 1 + .../pcsx2.ts | 6 +- .../package.json | 1 + .../ppsspp.ts | 6 +- .../package.json | 1 + .../com.simeonradivoev.gameflow.xemu/xemu.ts | 4 +- .../package.json | 1 + .../xenia.ts | 9 +- .../com.simeonradivoev.gameflow.es/es-de.ts | 520 ++++++++++++++++++ .../package.json | 13 + .../package.json | 13 + .../rclone.ts | 292 ++++++++++ .../com.simeonradivoev.gameflow.igdb/igdb.ts | 83 +++ .../package.json | 13 + .../package.json | 1 + .../com.simeonradivoev.gameflow.romm/romm.ts | 144 +++-- .../package.json | 13 + .../services.ts | 313 +++++++++++ .../store.ts | 312 +++++++++++ src/bun/api/plugins/plugin-manager.ts | 62 ++- src/bun/api/plugins/plugins.ts | 19 +- src/bun/api/plugins/register-plugins.ts | 22 +- src/bun/api/schema/app.ts | 6 +- src/bun/api/settings/services.ts | 18 +- src/bun/api/settings/settings.ts | 52 +- .../api/store/services/emulatorsService.ts | 54 +- src/bun/api/store/services/gamesService.ts | 67 +-- src/bun/api/store/store.ts | 120 ++-- src/bun/api/system.ts | 83 ++- src/bun/api/task-queue.ts | 13 +- src/bun/types/typesc.schema.ts | 36 +- src/mainview/App.tsx | 1 - src/mainview/components/AppCommunication.tsx | 30 +- src/mainview/components/CardElement.tsx | 26 +- src/mainview/components/CardList.tsx | 8 +- src/mainview/components/CollectionList.tsx | 3 +- src/mainview/components/CollectionsDetail.tsx | 6 +- src/mainview/components/Constants.tsx | 16 +- src/mainview/components/FrontEndGameCard.tsx | 28 +- src/mainview/components/GameList.tsx | 32 +- src/mainview/components/Header.tsx | 14 +- .../components/ImageWithFallbacks.tsx | 19 + src/mainview/components/LoadingCardList.tsx | 1 - src/mainview/components/LoadingScreen.tsx | 9 + src/mainview/components/PlatformsList.tsx | 58 +- src/mainview/components/SelectMenu.tsx | 18 +- src/mainview/components/game/ActionButton.tsx | 4 +- .../components/game/ActionButtons.tsx | 46 +- src/mainview/components/game/Details.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 5 +- .../components/options/OptionInput.tsx | 3 +- .../components/options/OptionSpace.tsx | 5 + .../components/store/EmulatorsSection.tsx | 4 +- src/mainview/gen/routeTree.gen.ts | 21 + src/mainview/preload.tsx | 13 +- src/mainview/routes/embedded.$source.$id.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 8 +- src/mainview/routes/index.tsx | 10 +- src/mainview/routes/platform.$source.$id.tsx | 68 ++- src/mainview/routes/settings/accounts.tsx | 6 +- src/mainview/routes/settings/emulators.tsx | 4 +- .../routes/settings/plugin.$source.tsx | 158 ++++++ src/mainview/routes/settings/plugins.tsx | 78 ++- src/mainview/routes/settings/route.tsx | 28 +- .../routes/store/details.emulator.$id.tsx | 5 +- src/mainview/routes/store/tab/games.tsx | 38 +- src/mainview/routes/store/tab/index.tsx | 20 +- src/mainview/routes/store/tab/route.tsx | 1 + src/mainview/scripts/queries/plugins.ts | 9 + src/mainview/scripts/queries/romm.ts | 51 +- src/mainview/scripts/queries/settings.ts | 53 +- src/mainview/scripts/queries/store.ts | 2 + src/mainview/scripts/queries/system.ts | 10 + src/shared/constants.ts | 56 +- src/shared/types..d.ts | 32 +- src/tests/game-launching.test.ts | 27 +- src/tests/mock-roms/mock-emulator.exe | 1 + src/tests/mock-roms/mock-rom.iso | 1 + src/tests/preload.ts | 1 + vendors/es-de/emulators.darwin.x64.sqlite | Bin 180224 -> 176128 bytes vendors/es-de/emulators.haiku.x64.sqlite | Bin 135168 -> 131072 bytes vendors/es-de/emulators.linux.arm.sqlite | Bin 184320 -> 180224 bytes vendors/es-de/emulators.linux.x64.sqlite | Bin 221184 -> 217088 bytes vendors/es-de/emulators.win32.x64.sqlite | Bin 212992 -> 212992 bytes vendors/romm/custom-overrides.json | 22 +- 115 files changed, 4139 insertions(+), 1502 deletions(-) create mode 100644 drizzle/0002_flowery_rocket_raccoon.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 scripts/drizzle/es-de/0000_sparkling_banshee.sql create mode 100644 scripts/drizzle/es-de/meta/0000_snapshot.json create mode 100644 scripts/drizzle/es-de/meta/_journal.json create mode 100644 src/bun/api/hooks/store.ts create mode 100644 src/bun/api/jobs/reload-plugins-job.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts create mode 100644 src/mainview/components/ImageWithFallbacks.tsx create mode 100644 src/mainview/components/LoadingScreen.tsx create mode 100644 src/mainview/routes/settings/plugin.$source.tsx create mode 100644 src/tests/mock-roms/mock-emulator.exe create mode 100644 src/tests/mock-roms/mock-rom.iso diff --git a/bun.lock b/bun.lock index 0b550d6..22e9e88 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -25,6 +26,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -32,7 +34,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6", @@ -58,8 +59,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -456,6 +459,10 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@phalcode/ts-apicalypse": ["@phalcode/ts-apicalypse@1.0.26", "", { "dependencies": { "axios": "^1.11.0" } }, "sha512-RdqkuunEYu63hRs4tYZ6FLTC17ynC6AJ/YUppRGSIyr6pm5pI/vB1qlEaeUr/f4JJsNmbFwGjnMJdXvoP1LmWA=="], + + "@phalcode/ts-igdb-client": ["@phalcode/ts-igdb-client@1.0.26", "", { "dependencies": { "@phalcode/ts-apicalypse": "^1.0.26", "axios": "^1.11.0" } }, "sha512-ITBazxhafHDBVJFI6THrLOT8OuO4zhD9pOeKQUFJ80soKhBevvbJz3tzkt24fF783Hoqaja8rWmGSwcN04d5gA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], @@ -634,6 +641,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -692,7 +701,7 @@ "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -958,7 +967,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -1314,6 +1323,10 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -1390,7 +1403,7 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], @@ -1638,10 +1651,6 @@ "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], - "ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="], - - "ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1868,6 +1877,8 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql new file mode 100644 index 0000000..0d8fa7e --- /dev/null +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_games` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source_id` text, + `source` text, + `igdb_id` integer, + `name` text, + `ra_id` integer, + `path_fs` text, + `main_glob` text, + `last_played` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `metadata` text DEFAULT '{}' NOT NULL, + `slug` text, + `platform_id` integer NOT NULL, + `cover` blob, + `type` text, + `summary` text, + `version` text, + `version_source` text, + `version_system` text, + FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system" FROM `games`;--> statement-breakpoint +DROP TABLE `games`;--> statement-breakpoint +ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fb182f2 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,479 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40569ae5-facd-4680-bd48-fe70c5abf498", + "prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643", + "tables": { + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections_games": { + "name": "collections_games", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_games_collection_id_collections_id_fk": { + "name": "collections_games_collection_id_collections_id_fk", + "tableFrom": "collections_games", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "collections_games_game_id_games_id_fk": { + "name": "collections_games_game_id_games_id_fk", + "tableFrom": "collections_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_fs": { + "name": "path_fs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "main_glob": { + "name": "main_glob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_played": { + "name": "last_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_source": { + "name": "version_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_system": { + "name": "version_system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "games_igdb_id_unique": { + "name": "games_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "games_ra_id_unique": { + "name": "games_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "games_slug_unique": { + "name": "games_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "games_platform_id_platforms_id_fk": { + "name": "games_platform_id_platforms_id_fk", + "tableFrom": "games", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platforms": { + "name": "platforms", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_slug": { + "name": "igdb_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moby_id": { + "name": "moby_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "es_slug": { + "name": "es_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "platforms_igdb_id_unique": { + "name": "platforms_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "platforms_igdb_slug_unique": { + "name": "platforms_igdb_slug_unique", + "columns": [ + "igdb_slug" + ], + "isUnique": true + }, + "platforms_moby_id_unique": { + "name": "platforms_moby_id_unique", + "columns": [ + "moby_id" + ], + "isUnique": true + }, + "platforms_es_slug_unique": { + "name": "platforms_es_slug_unique", + "columns": [ + "es_slug" + ], + "isUnique": true + }, + "platforms_ra_id_unique": { + "name": "platforms_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "platforms_slug_unique": { + "name": "platforms_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screenshots": { + "name": "screenshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screenshots_game_id_games_id_fk": { + "name": "screenshots_game_id_games_id_fk", + "tableFrom": "screenshots", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c181729..0df44e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772998956867, "tag": "0001_outstanding_silk_fever", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1776111721964, + "tag": "0002_flowery_rocket_raccoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3d5f135..79ef69a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -65,6 +66,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -72,7 +74,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6" @@ -98,8 +99,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/scripts/dev.ts b/scripts/dev.ts index b7c07f5..b8d3f6d 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -20,7 +20,7 @@ function spawnServer () HEADLESS: "true", }, stdout: "pipe", - stderr: "inherit", + stderr: "pipe", stdin: "pipe", signal: abortController.signal, killSignal: 'SIGUSR1', @@ -40,6 +40,11 @@ function spawnServer () console.log(e); } }); + const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) }); + rle.on('line', e => + { + console.error(e); + }); return s; } diff --git a/scripts/drizzle/es-de/0000_sparkling_banshee.sql b/scripts/drizzle/es-de/0000_sparkling_banshee.sql new file mode 100644 index 0000000..c86dd0e --- /dev/null +++ b/scripts/drizzle/es-de/0000_sparkling_banshee.sql @@ -0,0 +1,34 @@ +CREATE TABLE `commands` ( + `system` text, + `label` text, + `command` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `emulators` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `systempath` text DEFAULT (json_array()) NOT NULL, + `staticpath` text DEFAULT (json_array()) NOT NULL, + `corepath` text DEFAULT (json_array()) NOT NULL, + `androidpackage` text DEFAULT (json_array()) NOT NULL, + `winregistrypath` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint +CREATE TABLE `systemMappings` ( + `source` text, + `sourceSlug` text, + `sourceId` integer, + `system` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `systems` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `path` text, + `extension` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`); \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/0000_snapshot.json b/scripts/drizzle/es-de/meta/0000_snapshot.json new file mode 100644 index 0000000..5c40c18 --- /dev/null +++ b/scripts/drizzle/es-de/meta/0000_snapshot.json @@ -0,0 +1,234 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "commands": { + "name": "commands", + "columns": { + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "commands_system_systems_name_fk": { + "name": "commands_system_systems_name_fk", + "tableFrom": "commands", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emulators": { + "name": "emulators", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "systempath": { + "name": "systempath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "staticpath": { + "name": "staticpath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "corepath": { + "name": "corepath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "androidpackage": { + "name": "androidpackage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "winregistrypath": { + "name": "winregistrypath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "emulators_name_unique": { + "name": "emulators_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systemMappings": { + "name": "systemMappings", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceSlug": { + "name": "sourceSlug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "systemMappings_system_systems_name_fk": { + "name": "systemMappings_system_systems_name_fk", + "tableFrom": "systemMappings", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systems": { + "name": "systems", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "systems_name_unique": { + "name": "systems_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/_journal.json b/scripts/drizzle/es-de/meta/_journal.json new file mode 100644 index 0000000..65dbc00 --- /dev/null +++ b/scripts/drizzle/es-de/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1776039605377, + "tag": "0000_sparkling_banshee", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 115c19f..8e8fa74 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) => }); const rommMapping = rommPlatforms.data?.find(p => - p.slug === (customMappings as any)[name] || - p.slug === name || - p.igdb_slug === name || - p.hltb_slug === name || - p.moby_slug === name || - p.display_name === fullname + { + const custom = (customMappings as any)[name]; + if (Array.isArray(custom) && custom.some(m => m === p.slug)) + { + return true; + } + + return p.slug === custom || + p.slug === name || + p.igdb_slug === name || + p.display_name === fullname; + } ); const mappings: { diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 350607e..e4401bb 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -18,13 +18,13 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import UpdateStoreJob from "./jobs/update-store"; import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; export let config: Conf; export let customEmulators: Conf>; @@ -72,7 +72,6 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); - console.log("Store Directory is ", getStoreFolder()); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); @@ -84,14 +83,14 @@ export async function load () emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); await reloadDatabase(); plugins = new PluginManager(); - await registerPlugins(plugins); api = await RunAPIServer(); + await registerPlugins(plugins); + taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); controlsHandle = await controls(); if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); config.onDidChange('downloadPath', () => reloadDatabase()); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); - taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); } export async function cleanup () @@ -120,6 +119,7 @@ export async function reloadDatabase () db = drizzle(sqlite, { schema }); cache = drizzle(cacheSqlite, { schema: cacheSchema }); migrate(db!, { migrationsFolder: appPath("./drizzle") }); + sqlite.run("PRAGMA foreign_keys = ON;"); await cache.run(` CREATE TABLE IF NOT EXISTS item_cache ( key TEXT PRIMARY KEY, diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index 247ce6a..9e81b46 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -70,13 +70,13 @@ export default new Elysia({ prefix: '/emulatorjs' }) const localGame = await getLocalGame(source, id); if (!localGame) return status("Not Found"); - const changedSaveFiles: SaveFileChange[] = []; + const changedSaveFiles: Record = {}; if (save) { const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); const saveFile = path.join(savesPath, save.name); await Bun.write(saveFile, save); - changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false }; events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); } await updateLocalLastPlayed(localGame.id); @@ -85,7 +85,7 @@ export default new Elysia({ prefix: '/emulatorjs' }) id, saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), gameInfo: { platformSlug: localGame?.platform.slug }, - changedSaveFiles: changedSaveFiles, + changedSaveFiles: [], validChangedSaveFiles: changedSaveFiles, command: { id: "EMULATORJS", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 8e489cf..b7abb35 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,26 +1,26 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService"; +import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; +import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import * as emulatorSchema from '@schema/emulators'; -import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; -import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; +import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; +import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -58,8 +58,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, if (typeof img === 'string') { - const rommFetch = await fetch(img); - return rommFetch; + const res = await fetch(img); + + return new Response(res.body, { + status: res.status, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "image/jpeg", + "Cache-Control": "public, max-age=86400", + }, + }); } return img; @@ -135,190 +142,144 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; - const filterSets: FrontEndFilterSets = { - age_ratings: new Set(), - player_counts: new Set(), - languages: new Set(), - companies: new Set(), - genres: new Set() - }; - if (query.source === 'store') + const where: any[] = []; + let localGamesSet: Set | undefined; + + if (query.platform_slug) { - const shuffledGames = await getShuffledStoreGames(); - set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames.filter(g => + where.push(eq(schema.platforms.slug, query.platform_slug)); + } else if (query.platform_id && query.platform_source === 'local') + { + where.push(eq(schema.platforms.id, query.platform_id)); + } + else if (query.platform_id && query.platform_source) + { + const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined }); + if (platform) { - if (query.search) - return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); - return true; - }) - .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) - .map(async (e) => + where.push(eq(schema.platforms.slug, platform?.slug)); + } + } + + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); + } + + const ordering: any[] = []; + + if (query.orderBy) + { + switch (query.orderBy) + { + case 'added': + ordering.push(desc(schema.games.created_at)); + break; + case 'activity': + ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`); + break; + case 'name': + ordering.push(desc(schema.games.name)); + break; + case "release": + ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`); + break; + } + } + + const localGames = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .orderBy(...ordering) + .where(and(...where)); + + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); + + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); - const localGame = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`))); - - if (localGame.length > 0) return convertLocalToFrontend(localGame[0]); - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - games.push(...storeGames.filter(g => g !== undefined)); } else { - const where: any[] = []; - let localGamesSet: Set | undefined; - - if (query.platform_slug) + games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g => { - where.push(eq(schema.platforms.slug, query.platform_slug)); - } else if (query.platform_id && query.platform_source === 'local') - { - where.push(eq(schema.platforms.id, query.platform_id)); - } - else if (query.platform_id && query.platform_source) - { - const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) }); - if (platform) + if (query.genres && query.genres.length > 0) { - where.push(eq(schema.platforms.slug, platform?.slug)); + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; } - } - if (query.search) + return true; + }).map(g => { - where.push(like(schema.games.name, query.search)); - } + return convertLocalToFrontend(g); + })); - if (query.source) + if (query.localOnly !== true) { - where.push(eq(schema.games.source, query.source)); - } - - const localGames = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(...where)); - - localGamesSet = new Set( - localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) - .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) - ); - - function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) - { - if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; - if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; - if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; - return false; - } - - if (query.collection_id) - { - // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + return false; } - else - { - return g; - } - })); - } else - { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => - { - if (query.genres && query.genres.length > 0) + if (g.igdb_id) { - if (!g.metadata) return false; - if (!g.metadata.genres) return false; - if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); } return true; - }).map(g => - { - return convertLocalToFrontend(g); })); - - if (query.localOnly !== true) - { - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => - { - if (localGameExistsPredicate(g)) - { - return false; - } - - if (g.igdb_id) - { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); - } - - if (g.ra_id) - { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); - } - - return true; - })); - } else - { - await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); - } - - localGames.map(g => - { - const metadata: any = g.metadata; - if (metadata.genres && Array.isArray(metadata.genres)) - { - metadata.genres.forEach((g: string) => filterSets.genres.add(g)); - } - if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) - { - metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); - } - if (metadata.companies && Array.isArray(metadata.companies)) - { - metadata.companies.forEach((g: string) => filterSets.companies.add(g)); - } - if (metadata.player_count) - { - filterSets.player_counts.add(metadata.player_count); - } - }); } } @@ -342,7 +303,37 @@ export default new Elysia() } - const filterLists: FrontEndFilterLists = { + return { games }; + }, { + query: GameListFilterSchema, + }) + .get('/games/filters', async ({ query: { source } }) => + { + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; + + let filter: any = undefined; + if (source) filter = eq(schema.games.source, source); + const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter }); + + local_metadata.forEach(game => + { + game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r)); + game.metadata.genres?.forEach(r => filterSets.genres.add(r)); + game.metadata.companies?.forEach(r => filterSets.companies.add(r)); + + if (game.metadata.player_count) + filterSets.player_counts.add(game.metadata.player_count); + }); + + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source }); + + const filters: FrontEndFilterLists = { age_ratings: Array.from(filterSets.age_ratings), player_counts: Array.from(filterSets.player_counts), languages: Array.from(filterSets.languages), @@ -350,34 +341,21 @@ export default new Elysia() genres: Array.from(filterSets.genres) }; - return { games, filters: filterLists }; + return filters; }, { - query: GameListFilterSchema, + query: z.object({ source: z.string().optional() }) }) .get('/rom/:source/:id', async ({ params: { id, source } }) => { - const localGame = await db.query.games.findFirst({ - where: getLocalGameMatch(id, source), - columns: { path_fs: true }, - with: { platform: { columns: { es_slug: true } } } - }); + const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id }); - if (!localGame?.path_fs) + if (!filePaths || filePaths.length <= 0) { - return status("Not Found"); + return status("Not Found", "No Valid Roms Found"); } - const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); + return Bun.file(filePaths[0]); - const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined); - - if (filesPaths.length <= 0) - { - throw new Error("No Valid Roms Found"); - } - - return Bun.file(filesPaths[0]); }, { params: z.object({ source: z.string(), id: z.string() }) }) @@ -392,17 +370,12 @@ export default new Elysia() const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); if (systemMapping) { - const emulatorNames = await getEmulatorsForSystem(systemMapping.system); - const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); + const emulatorNames: string[] = []; + await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames }); - sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => + sourceData.emulators = (await Promise.all(emulatorNames.map(async name => { - if (data) - { - const systems = await buildStoreFrontendEmulatorSystems(data); - return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true }; - } - else if (name === 'EMULATORJS') + if (name === 'EMULATORJS') { return { name: 'EMULATORJS', @@ -424,22 +397,34 @@ export default new Elysia() return system; })), gameCount: 0, - integrations: [] - } satisfies FrontEndGameTypeDetailedEmulator; - } - else - { - return { - name: name, - logo: "", - systems: [], - gameCount: 0, - validSources: [], + source: 'local', integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } - })); + const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name }); + + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(id, execPaths); + + if (foundEmulator) + { + foundEmulator.validSources = execPaths; + foundEmulator.integrations = integrations; + return foundEmulator; + } + + return { + name: name, + logo: "", + source: 'local', + systems: [], + gameCount: 0, + validSources: execPaths, + integrations: integrations + } satisfies FrontEndGameTypeDetailedEmulator; + }))).filter(e => !!e); } } @@ -466,17 +451,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId })); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.string(), source: z.string() }), + query: z.object({ downloadId: z.string().optional() }), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => @@ -501,6 +487,10 @@ export default new Elysia() { return fixSource(source, id); }) + .post('/game/:source/:id/update', async ({ params: { id, source } }) => + { + return update(source, id); + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); @@ -559,8 +549,6 @@ export default new Elysia() const emulator = await getStoreEmulatorPackage(id); if (!emulator) return status("Not Found"); const systems = await buildStoreFrontendEmulatorSystems(emulator); - const systemsIdSet = new Set(systems.map(s => s.id)); - const games: FrontEndGameType[] = []; @@ -587,28 +575,6 @@ export default new Elysia() await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames }); games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - const gamesManifest = await getStoreGameManifest(); - const storeGames = await Promise.all(gamesManifest - .filter(g => systemsIdSet.has(path.dirname(g.path))) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - - const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); - - if (localGame) - { - return undefined; - } - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - - games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); - return games; }) .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => @@ -619,7 +585,7 @@ export default new Elysia() const sourceCompaniesSet = new Set(sourceData.metadata.companies); const sourceGenresSet = new Set(sourceData.metadata.genres); - const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; + const games: (FrontEndGameType & { metadata?: any; })[] = []; @@ -632,35 +598,7 @@ export default new Elysia() games.push(...localGames.map(g => convertLocalToFrontend(g))); - const shuffledGames = await getShuffledStoreGames(); - const storeGames = await Promise.all(shuffledGames - .filter(g => - { - const system = path.dirname(g.path); - const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`store@${system}@${id}`)) - return false; - - if (esSystem) - { - if (path.dirname(g.path) === esSystem.system) return true; - } - - return false; - }) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - const storeGame = await getStoreGameFromPath(e.path); - return convertStoreToFrontend(system, id, storeGame); - })); - - if (storeGames) - { - games.push(...storeGames.slice(0, 3)); - } const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; plugins.hooks.games.fetchRecommendedGamesForGame.promise({ diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index a33e155..22161ae 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not } from "drizzle-orm"; +import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; import { db, plugins } from "../app"; import * as schema from "@schema/app"; @@ -93,7 +93,8 @@ export default new Elysia() if (!remotePlatform) return status("Not Found"); return remotePlatform; } - }, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => + }, { params: z.object({ source: z.string(), id: z.string() }) }) + .get('/platform/local/:id/cover', async ({ params: { id }, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; @@ -112,4 +113,35 @@ export default new Elysia() set.headers["content-type"] = coverBlob.cover_type; } return status(200, coverBlob.cover); - }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }); \ No newline at end of file + }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) + .post('/platform/local/:id/update', async ({ params: { id } }) => + { + const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); + if (!localPlatform) return status("Not Found"); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: localPlatform.slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + await db.update(schema.platforms).set({ + name: platformLookup?.name, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + }).where(eq(schema.platforms.id, localPlatform.id)); + }) + .delete('/platform/local/:id', async ({ params: { id } }) => + { + const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)), + notExists( + db + .select() + .from(schema.games) + .where(eq(schema.games.platform_id, Number(id))) + ))).returning(); + if (deleted.length <= 0) return status("Not Found"); + }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 4dde81b..1a0dddc 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -1,19 +1,12 @@ import path from 'node:path'; -import { Glob, which } from 'bun'; +import { Glob } from 'bun'; import fs from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; -import * as schema from '@schema/emulators'; -import { eq } from 'drizzle-orm'; -import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os from 'node:os'; -import { cores } from '../../emulatorjs/emulatorjs'; +import { existsSync } from 'node:fs'; +import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; -export const varRegex = /%([^%]+)%/g; -export const assignRegex = /(%\w+%)=(\S+) /g; - export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) @@ -24,285 +17,6 @@ export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } -/** - * Get the emulators related to the given system - * @param systemSlug the ES-DE slug for the system - */ -export async function getEmulatorsForSystem (systemSlug: string) -{ - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const emulators = new Set(); - await Promise.all(system.commands.map(async (command, index) => - { - let cmd = command.command; - - const matches = Array.from(cmd.matchAll(varRegex)); - matches.forEach(([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - emulators.add(emulatorName); - return; - } - }); - })); - - - - if (cores[systemSlug]) - { - emulators.add('EMULATORJS'); - } - - return Array.from(emulators); -} - -export async function getRomFilePaths (gamePath: string, systemSlug?: string) -{ - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - const validFiles: string[] = []; - - if (gamePathStat.isDirectory()) - { - if (!systemSlug) throw new Error("Needs system to find valid file"); - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const extensionList = system.extension.join(','); - - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else if (systemSlug) - { - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - const extensionList = system.extension.join(','); - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } else - { - validFiles.push(gamePath); - } - - return validFiles; -} - -/** - * - * @param data Uses es-de system slug - * @returns - */ -export async function getValidLaunchCommands (data: { - systemSlug: string; - gamePath: string; -}): Promise -{ - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, data.systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${data.systemSlug}'`); - } - - if (!system.extension || system.extension.length <= 0) - { - throw new Error(`No extensions listed for system '${data.systemSlug}'`); - } - - const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); - - const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug); - - function escapeWindowsArg (arg: string): string - { - if (process.platform === 'win32') - { - return `"${arg - .replace(/(\\*)"/g, '$1$1\\"') // escape quotes - .replace(/(\\*)$/, '$1$1') // escape trailing backslashes - }"`; - } else - { - if (arg.includes(' ')) - { - return `"${arg}"`; - } else - { - return arg; - } - } - } - - const formattedCommands = await Promise.all(system.commands - .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) - .map(async (command, index) => - { - const label = command.label; - let cmd = command.command; - - let emulator: string | undefined = undefined; - let rom = validFiles[0]; - - if (cmd.includes('%ESCAPESPECIALS%')) - rom = rom.replace(/[&()^=;,]/g, ''); - - - - const staticVars: Record = { - '%ROM%': escapeWindowsArg(rom), - '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), - '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), - '%ROMPATH%': escapeWindowsArg(gamePath), - '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), - '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), - '%ESCAPESPECIALS%': "", - '%HIDEWINDOW%': "" - }; - - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => - { - try - { - const resolvedInjectFile = injectFile.replace(varRegex, (a) => - { - return staticVars[a] ?? a; - }); - if (existsSync(resolvedInjectFile)) - { - const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); - return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); - } - - return ''; - } catch (error) - { - return ''; - } - }); - - const matches = Array.from(cmd.matchAll(varRegex)); - const varList = await Promise.all(matches.map(async ([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let execs = await findExecsByName(emulatorName); - let validExec = execs.find(e => e.exists); - - emulator = emulatorName; - return [ - [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], - [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], - ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], - ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] - ]; - - } - - const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]] as [string, string | undefined]]; - })); - - const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; - let startDir: string | undefined = undefined; - - if ('%STARTDIR%' in vars) - { - delete vars['%STARTDIR%']; - - cmd = cmd.replace(assignRegex, (match, p1, p2) => - { - if (p1 === '%STARTDIR%') - { - startDir = varRegex.test(p2) ? staticVars[p2] : p2; - } - return ""; - }); - } - - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); - - const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); - - return { - id: index, - label: label ?? undefined, - command: formattedCommand, - startDir, - valid: !invalid, emulator, - emulatorSource: vars['%EMUSOURCE%'] as any, - metadata: { - romPath: validFiles[0], - emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], - emulatorDir: vars['%EMUDIRRAW%'] - } - } satisfies CommandEntry; - })); - - return formattedCommands.filter(c => !!c); -} - -export async function findExecsByName (emulatorName: string) -{ - const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); - if (!emulator) - { - throw new Error(`Could not find emulator ${emulatorName}`); - } - return findExecs(emulatorName, emulator); -} - export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise { const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); @@ -355,112 +69,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath return undefined; } -export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) -{ - const execs: EmulatorSourceEntryType[] = []; - - if (customEmulators.has(id)) - { - execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); - } - - if (emulator && emulator.systempath.length > 0) - { - const storePath = await findStoreEmulatorExec(id, emulator); - if (storePath) execs.push(storePath); - } - - if (emulator && os.platform() === 'win32') - { - const regValues = emulator.winregistrypath; - if (regValues.length > 0) - { - for (const node of regValues) - { - const registryValue = await readRegistryValue(node); - if (registryValue) - { - execs.push({ binPath: registryValue, type: 'registry', exists: true }); - } - } - - } - } - - if (emulator && emulator.systempath.length > 0) - { - const systemPath = await resolveSystemPath(emulator.systempath); - if (systemPath) - { - execs.push({ binPath: systemPath, type: 'system', exists: true }); - } - } - - if (emulator && emulator.staticpath.length > 0) - { - const staticPath = await resolveStaticPath(emulator.staticpath); - if (staticPath) - { - execs.push({ binPath: staticPath, type: 'static', exists: true }); - } - } - - return execs; -} - -async function readRegistryValue (text: string) -{ - const params = text.split('|'); - const key = path.dirname(params[0]); - const value = path.basename(params[0]); - const bin = params.length > 1 ? params[1] : undefined; - - const proc = Bun.spawn({ - cmd: ["reg", "QUERY", key, "/v", value], - stdout: "pipe", - stderr: "pipe", - }); - - const output = await new Response(proc.stdout).text(); - await proc.exited; - - if (!output.includes(value)) return null; - - const lines = output.split("\n"); - for (const line of lines) - { - if (line.includes(value)) - { - const parts = line.trim().split(/\s{4,}/); - return bin ? path.join(parts[2], bin) : parts[2]; // registry value - } - } - - return null; -} - -async function resolveStaticPath (entries: string[]) -{ - for (const entry of entries) - { - const resolved = entry.replace("~", os.homedir()); - if (await fs.exists(resolved)) - { - return resolved; - } - } - return null; -} - -async function resolveSystemPath (entries: string[]) -{ - for (const entry of entries) - { - try - { - const found = which(entry); - return found; - } catch { } - } - return null; -} \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 53ec3de..82f69d4 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,21 +1,17 @@ -import { RPC_URL, } from "@shared/constants"; -import { config, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { findExecs, getValidLaunchCommands } from "./launchGameService"; -import * as emulatorSchema from '@schema/emulators'; -import { and, eq } from "drizzle-orm"; +import { config, db, plugins, taskQueue } from "../../app"; +import { eq } from "drizzle-orm"; import { getErrorMessage } from "@/bun/utils"; -import { checkFiles, getLocalGameMatch } from "./utils"; +import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; -import { getStoreGameFromId } from "../../store/services/gamesService"; -import { cores } from "../../emulatorjs/emulatorjs"; -import { host } from "@/bun/utils/host"; import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; -class CommandSearchError extends Error +export class CommandSearchError extends Error { constructor(status: GameStatusType, message: string) { @@ -33,7 +29,8 @@ export async function getLocalGame (source: string, id: string) source: true, source_id: true, igdb_id: true, - ra_id: true + ra_id: true, + main_glob: true }, where: getLocalGameMatch(id, source), with: { @@ -44,6 +41,59 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function update (source: string, id: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined"); + const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true }); + if (!sourceGame) throw new Error("Could not find source game"); + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; + if (paths_screenshots.length <= 0 && sourceGame.igdb_id) + { + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); + if (igdbLookup) + { + paths_screenshots.push(...igdbLookup.screenshotUrls); + } + } + + // pre-fetch screenshots + const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + await tx.update(appSchema.games).set({ + metadata: { + age_ratings: sourceGame.metadata.age_ratings, + genres: sourceGame.metadata.genres, + player_count: sourceGame.metadata.player_count ?? undefined, + companies: sourceGame.metadata.companies, + game_modes: sourceGame.metadata.game_modes, + average_rating: sourceGame.metadata.average_rating ?? undefined, + first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + export async function fixSource (source: string, id: string) { const valid = await validateGameSource(source, id); @@ -94,12 +144,10 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { - // Store should be immutable - if (localGame.source === 'store') return { valid: true, localGame }; - const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; - if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + // Store should be immutable + if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { return { valid: false, reason: "Metadata Missmatch", localGame }; } @@ -115,79 +163,34 @@ export async function updateLocalLastPlayed (id: number) export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { - if (source === 'emulator') - { - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) }); - const allExecs = await findExecs(id, esEmulator); - return { - commands: allExecs.map(exec => ({ - command: exec.binPath, - id: exec.type, - emulator: id, - emulatorSource: exec.type, - metadata: { - emulatorBin: exec.binPath, - emulatorDir: exec.rootPath - }, - valid: true - } satisfies CommandEntry)), - gameId: { source: "emulator", id: id } - }; - } const localGame = await getLocalGame(source, id); if (localGame) { - const rommPlatform = localGame.platform.slug; - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source: localGame.source, + sourceId: localGame.source_id, + id: { source: 'local', id: String(localGame.id) }, + systemSlug: localGame.platform.slug, + gamePath: localGame.path_fs, + mainGlob: localGame.main_glob, + }); - if (esPlatform) + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) { - if (localGame.path_fs) - { - try - { - const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs }); - - if (cores[esPlatform.system]) - { - const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; - commands.push({ - id: 'EMULATORJS', - label: "Emulator JS", - command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`, - valid: true, - emulator: 'EMULATORJS', - metadata: { - romPath: gameUrl - } - }); - } - - const validCommand = commands.find(c => c.valid); - if (validCommand) - { - return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: String(localGame.source_id) ?? id }; - } - else - { - return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); - } - } catch (error) - { - console.error(error); - return new CommandSearchError('error', getErrorMessage(error)); - } - - } else - { - return new CommandSearchError('error', 'Missing Path'); - } + return { + commands: commands.filter(c => c.valid), + gameId: { id: String(localGame.id), source: 'local' }, + source: localGame.source ?? source, + sourceId: String(localGame.source_id) ?? id, + }; } else { - return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); } - } return undefined; @@ -239,6 +242,7 @@ export default function buildStatusResponse () } else { + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) }); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { @@ -255,9 +259,9 @@ export default function buildStatusResponse () }); } - } else if (ws.data.params.source === 'store') + } else if (!localGame && ws.data.params.source === 'store') { - const storeGame = await getStoreGameFromId(ws.data.params.id); + /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); @@ -268,8 +272,10 @@ export default function buildStatusResponse () } else { ws.send({ status: 'install', details: 'Install' }); - } - } else + }*/ + + ws.send({ status: 'install', details: 'Install' }); + } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, @@ -302,8 +308,9 @@ export default function buildStatusResponse () ws.send({ status: 'install', details: 'Install' }); } } - - + } else + { + ws.send({ status: 'error', error: "No Way To Launch" }); } } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 955d1e7..b1b6fc2 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -4,10 +4,10 @@ import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; -import { StoreGameType } from "@shared/constants"; -import * as emulatorSchema from "@schema/emulators"; -import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; +import { RPC_URL, StoreGameType } from "@shared/constants"; import { hashFile } from "@/bun/utils"; +import { host } from "@/bun/utils/host"; +import secrets from "../../secrets"; export async function calculateSize (installPath: string | null) { @@ -21,6 +21,11 @@ export async function checkInstalled (installPath: string | null) return fs.exists(path.join(config.get('downloadPath'), installPath)); } +export function getScreenshotLocalGameMatch (id: string, source: string) +{ + return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); +} + export function getLocalGameMatch (id: string, source: string) { return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); @@ -35,7 +40,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -67,7 +72,7 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -82,6 +87,11 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in fs_size_bytes: fileSize, missing: !exists, local: true, + ra_id: g.ra_id, + version: g.version, + version_source: g.version_source, + version_system: g.version_system, + igdb_id: g.igdb_id, metadata: { genres: g.metadata.genres ?? [], companies: g.metadata.companies ?? [], @@ -96,74 +106,6 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in return game; } -export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise -{ - const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) - }); - - const platformDef = await emulatorsDb.query.systems.findFirst({ - where: eq(emulatorSchema.systems.name, system), - columns: { fullname: true } - }); - - const gameId = `${system}@${id}`; - - const game: FrontEndGameType = { - platform_display_name: platformDef?.fullname ?? system, - path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`, - id: { source: 'store', id: gameId }, - source: null, - source_id: null, - path_fs: null, - path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`, - last_played: null, - updated_at: new Date(), - slug: null, - name: storeGame.title, - platform_id: null, - platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], - metadata: { - first_release_date: null - } - }; - - return game; -} - -export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise -{ - let size: number | null = null; - try - { - const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); - size = Number(fileResponse.headers.get('content-length')); - } catch (error) - { - console.error(error); - } - - const detailed: FrontEndGameTypeDetailed = { - ...await convertStoreToFrontend(system, id, storeGame), - summary: storeGame.description, - fs_size_bytes: size, - missing: false, - local: false, - metadata: { - genres: storeGame.tags, - companies: [], - game_modes: [], - age_ratings: [], - player_count: "", - average_rating: null, - first_release_date: null - } - }; - - return detailed; -} - export async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ @@ -182,7 +124,7 @@ export async function getLocalGameDetailed (match: any) return undefined; } -export async function getSourceGameDetailed (source: string, id: string) +export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; }) { if (source === 'local') { @@ -194,30 +136,13 @@ export async function getSourceGameDetailed (source: string, id: string) { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (source === 'store') + const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); + if (localGame && options?.sourceOnly !== true) { - const gameId = extractStoreGameSourceId(id); - const storeGame = await getStoreGame(gameId.system, gameId.id); - if (!storeGame) return undefined; - const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); - if (localGame) - { - return { ...storeFrontendGame, ...localGame }; - } - return storeFrontendGame; - } else - { - const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); - if (remoteGame) - { - return remoteGame; - } else if (localGame) - { - return localGame; - } + return localGame; } - return undefined; + return remoteGame; } } diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts index bf592a0..a1f0eec 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/bun/api/hooks/app.ts @@ -1,10 +1,12 @@ import { AuthHooks } from "./auth"; import { EmulatorHooks } from "./emulators"; import { GameHooks } from "./games"; +import { StoreHooks } from "./store"; export class GameflowHooks { games = new GameHooks(); emulators = new EmulatorHooks(); auth = new AuthHooks(); + store = new StoreHooks(); } \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index 6740b30..4ac51e7 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -22,6 +22,8 @@ export class EmulatorHooks * Triggered when emulator is downloaded or updated */ emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); + findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); constructor() { diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index f1f4a6a..f4ae463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -3,6 +3,14 @@ import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfal export class GameHooks { + buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { + source: string | null; + sourceId: string | null; + id: FrontEndId; + systemSlug: string; + gamePath: string | null, + mainGlob?: string | null, + }], CommandEntry[] | Error | undefined>(['ctx']); /** override the launch command for an emulator * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing * @param ctx.emulator The emulator ID if any @@ -20,7 +28,7 @@ export class GameHooks id: FrontEndId; platformSlug?: string; }; - }], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: SaveSlots; env?: Record; } | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -37,9 +45,9 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; - filters: FrontEndFilterSets; }]>(['ctx']); fetchFilters = new AsyncSeriesHook<[ctx: { + source?: string; filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { @@ -58,7 +66,12 @@ export class GameHooks fetchDownloads = new AsyncSeriesBailHook<[ctx: { source: string; id: string; + downloadId?: string; }], DownloadInfo | undefined>(['ctx']); + fetchRomFiles = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], string[] | undefined>(['ctx']); fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { game: FrontEndGameTypeDetailed, games: (FrontEndGameType & { metadata?: any; })[]; @@ -73,28 +86,39 @@ export class GameHooks id: string; }], FrontEndPlatformType | undefined>(['ctx']); platformLookup = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], { slug: string; } | undefined>(['ctx']); + source?: string; + id?: string; + slug?: string; + }], { + slug: string; + url_logo?: string | null; + name?: string; + family_name?: string; + } | undefined>(['ctx']); + gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']); fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); prePlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; + saveFolderSlots: Record; setProgress: (progress: number, state: string) => void, command: CommandEntry; gameInfo: { platformSlug?: string; }; }]>(["ctx"]); + /** + * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay + * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone + */ postPlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; - changedSaveFiles: SaveFileChange[], - validChangedSaveFiles: SaveFileChange[], + saveFolderSlots?: Record; + changedSaveFiles: { subPath: string, cwd: string; }[], + validChangedSaveFiles: Record, command: CommandEntry; gameInfo: { platformSlug?: string; diff --git a/src/bun/api/hooks/store.ts b/src/bun/api/hooks/store.ts new file mode 100644 index 0000000..de889d1 --- /dev/null +++ b/src/bun/api/hooks/store.ts @@ -0,0 +1,10 @@ +import { EmulatorDownloadInfoType } from "@/shared/constants"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; + +export class StoreHooks +{ + fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']); + fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']); + fetchEmulator = new AsyncSeriesBailHook<[ctx: { id: string; }], FrontEndEmulatorDetailed | undefined>(['ctx']); + fetchDownload = new AsyncSeriesBailHook<[ctx: { id: string; }], (EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 0564111..07f4fb6 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -5,7 +5,6 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; -import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; import { simulateProgress } from "@/bun/utils"; @@ -13,17 +12,16 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir, existsSync } from "fs-extra"; +import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; -import slugify from 'slugify'; import StreamZip from 'node-stream-zip'; -import { createExtractorFromFile } from 'node-unrar-js'; import { which } from "bun"; interface JobConfig { dryRun?: boolean; dryDownload?: boolean; + downloadId?: string; } export type InstallJobStates = 'download' | 'extract'; @@ -55,34 +53,7 @@ export class InstallJob implements IJob const downloadPath = config.get('downloadPath'); let info: DownloadInfo | undefined; - switch (this.source) - { - case 'store': - const game = await getStoreGameFromId(this.gameId); - const gameId = extractStoreGameSourceId(this.gameId); - info = { - coverUrl: game.pictures.titlescreens[0], - screenshotUrls: game.pictures.screenshots, - files: [{ - url: new URL(game.file), - file_path: `roms/${game.system}`, - file_name: path.basename(decodeURI(game.file)), - size: 0 - }], - slug: this.gameId, - source_id: this.gameId, - name: game.title, - summary: game.description, - system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, slugify(game.title)), - extract_path: '.', - }; - - break; - default: - info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId }); - break; - } + info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); if (!info) throw new Error(`Could not find downloader for source ${this.source}`); @@ -116,9 +87,10 @@ export class InstallJob implements IJob { let progress = 0; const progressDelta = 1 / downloadedFiles.length; + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); + for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise(async (resolve, reject) => { let sevenZipPath = process.env.ZIP7_PATH ?? path7za; @@ -176,8 +148,23 @@ export class InstallJob implements IJob throw e; } }); + progress += progressDelta * 100; } + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(extractPath); + if (contents.length === 1) + { + const stat = await fs.stat(path.join(extractPath, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpGameFolder = `${extractPath} (1)`; + await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true }); + await move(tmpGameFolder, extractPath, { overwrite: true }); + } + } } } @@ -221,7 +208,15 @@ export class InstallJob implements IJob if (!existingPlatform) { // TODO: use something else than the romm demo as CDN - const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.slug ?? info.system_slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } if (!esPlatform && !info.platform) { @@ -251,7 +246,7 @@ export class InstallJob implements IJob cover_type: platformCover.headers.get('content-type'), name: info.platform?.name ?? esPlatform?.system.fullname ?? '', family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined + es_slug: esPlatform?.system.name ?? undefined, }; // TODO: add ES slug once I have better way to query ES @@ -278,22 +273,20 @@ export class InstallJob implements IJob name: info.name, cover, cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system }; const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) + if (info.screenshotUrls.length <= 0 && info.igdb_id) { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) - { - const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - - const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute(); - - info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); - } + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) }); + if (igdbLookup) return igdbLookup.screenshotUrls; + return []; } // pre-fetch screenshots diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 7bc92c7..328c04e 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -10,6 +10,7 @@ import { IJob } from "../task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; +import ReloadPluginsJob from "./reload-plugins-job"; function registerJob< const Path extends string, @@ -107,4 +108,5 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(UpdateStoreJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) + .use(registerJob(ReloadPluginsJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index bcea594..9159002 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -19,8 +19,8 @@ export class LaunchGameJob implements IJob; - saveFolderPath?: string; + changedSaveFiles: Map; + saveSlots: SaveSlots = {}; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -47,9 +47,8 @@ export class LaunchGameJob implements IJob console.error(e)); } @@ -59,7 +58,7 @@ export class LaunchGameJob implements IJob { console.error(e); - reject(e); + resolve(1); }); game = spawnGame; } else if (this.validCommand.metadata.emulatorBin) { - this.saveFolderPath = commandArgs.savesPath; + this.saveSlots = commandArgs.savesPath ?? {}; await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); @@ -154,12 +155,15 @@ export class LaunchGameJob implements IJob { diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts new file mode 100644 index 0000000..4796fc8 --- /dev/null +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -0,0 +1,15 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { plugins } from "../app"; + +export default class ReloadPluginsJob implements IJob +{ + static id = "reload-plugins-job" as const; + static dataSchema = z.never(); + group = "reload-plugins"; + + async start (context: JobContext, never, string>) + { + await plugins.reloadAll(context); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index cc3cfc7..7a0eadc 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginContextType, PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; @@ -7,7 +7,7 @@ export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return { args, savesPath: savesPath }; + return { args, savesPath: { cemu: { cwd: savesPath } } }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json index bbabba6..9a0c5c6 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -5,6 +5,7 @@ "description": "CEMU Emulator Integration", "main": "./cemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index aa993a3..de853f7 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; import { ensureDir } from "fs-extra"; @@ -10,7 +10,7 @@ export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -70,14 +70,18 @@ export default class DOLPHINIntegration implements PluginType finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return { args, savesPath: finalSavesPath }; + return { args, savesPath: { dolphin: { cwd: finalSavesPath } } }; }); - ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) => { - if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath) { - validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + validChangedSaveFiles.dolphin = { + cwd: saveFolderSlots.dolphin.cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; } }); } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json index 146b910..e413d06 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -5,6 +5,7 @@ "description": "DOLPHIN Emulator Integration", "main": "./dolphin.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts index 2514790..8794e80 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -128,10 +128,10 @@ async function getGCSavePaths (romPath: string, savesPath: string, location: Dol const cardPath = join(savesPath, "GC", region); const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); - const saves: SaveFileChange[] = []; + const saves: string[] = []; for await (const file of glob.scan(cardPath)) { - saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + saves.push(path.join("GC", region, file)); } return saves; @@ -145,7 +145,7 @@ export async function getType (romPath: string, bundledEmulatorDir?: string): Pr return isGameCube ? "gamecube" : "wii"; } -export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise { const location = await findDolphinTool(bundledEmulatorDir); const gameId = await readGameId(romPath, location); @@ -159,6 +159,6 @@ export async function getSavePaths (romPath: string, savesPath: string, bundledE const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); const files = await fs.readdir(rootFolder, { recursive: true }); - return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + return files.map(f => path.join("Wii", "title", "00010000", f)); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json index bab4f08..6b8c725 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -5,6 +5,7 @@ "description": "PCSX2 Emulator Integration", "main": "./pcsx2.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index db405a2..605c1b6 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; @@ -11,7 +11,7 @@ export default class PCSX2Integration implements PluginType { emulator = "PCSX2"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -103,7 +103,7 @@ export default class PCSX2Integration implements PluginType await Bun.write(configPath, ini.stringify(configFile)); - return { args, savesPath: paths.MEMORY_CARDS_PATH }; + return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json index f8e00f5..3801e34 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json @@ -5,6 +5,7 @@ "description": "PPSSPP Emulator Integration", "main": "./ppsspp.ts", "icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index b6ff93d..5f5dbbb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -15,7 +15,7 @@ export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { @@ -114,7 +114,7 @@ export default class PPSSPPIntegration implements PluginType await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } - return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; + return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json index 3a77c30..937ebc3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json @@ -5,6 +5,7 @@ "description": "XEMU Emulator Integration", "main": "./xemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index 010430c..fe0f65e 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import path from "node:path"; @@ -10,7 +10,7 @@ export default class XEMUIntegration implements PluginType { emulator = 'XEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json index a6b3d25..280f14f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -5,6 +5,7 @@ "description": "XENIA Emulator Integration", "main": "./xenia.ts", "icon": "https://xenia.jp/images/logo-256x256.png", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 6a021da..774b918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { GameflowHooks } from "@/bun/api/hooks/app"; import { config } from "@/bun/api/app"; @@ -68,9 +68,10 @@ export default class XENIAIntegration implements PluginType if (ctx.autoValidCommand.metadata.romPath) { finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + return { args, savesPath: { xenia: { cwd: finalSavesPath } } }; } - return { args, savesPath: finalSavesPath }; + return { args }; }; return { args }; @@ -82,7 +83,7 @@ export default class XENIAIntegration implements PluginType return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); @@ -95,7 +96,7 @@ export default class XENIAIntegration implements PluginType if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) { const files = await fs.readdir(saveFolderPath, { recursive: true }); - validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false }; } }); } diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts new file mode 100644 index 0000000..85b8aa0 --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -0,0 +1,520 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; +import * as emulatorSchema from '@schema/emulators'; +import { and, eq } from "drizzle-orm"; +import { cores } from "@/bun/api/emulatorjs/emulatorjs"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; +import path from 'node:path'; +import { existsSync, readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService"; +import { which } from "bun"; +import os from 'node:os'; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; + +export default class IgdbIntegration implements PluginType +{ + varRegex = /%([^%]+)%/g; + assignRegex = /(%\w+%)=(\S+) /g; + + /** + * Get the emulators related to the given system + * @param systemSlug the ES-DE slug for the system + */ + async getEmulatorsForSystem (systemSlug: string) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const emulators = new Set(); + await Promise.all(system.commands.map(async (command, index) => + { + let cmd = command.command; + + const matches = Array.from(cmd.matchAll(this.varRegex)); + matches.forEach(([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + emulators.add(emulatorName); + return; + } + }); + })); + + if (cores[systemSlug]) + { + emulators.add('EMULATORJS'); + } + + return Array.from(emulators); + } + + async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) + { + const execs: EmulatorSourceEntryType[] = []; + + if (customEmulators.has(id)) + { + execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); + } + + if (emulator && emulator.systempath.length > 0) + { + const storePath = await findStoreEmulatorExec(id, emulator); + if (storePath) execs.push(storePath); + } + + if (emulator && process.platform === 'win32') + { + const regValues = emulator.winregistrypath; + if (regValues.length > 0) + { + for (const node of regValues) + { + const registryValue = await this.readRegistryValue(node); + if (registryValue) + { + execs.push({ binPath: registryValue, type: 'registry', exists: true }); + } + } + + } + } + + if (emulator && emulator.systempath.length > 0) + { + const systemPath = await this.resolveSystemPath(emulator.systempath); + if (systemPath) + { + execs.push({ binPath: systemPath, type: 'system', exists: true }); + } + } + + if (emulator && emulator.staticpath.length > 0) + { + const staticPath = await this.resolveStaticPath(emulator.staticpath); + if (staticPath) + { + execs.push({ binPath: staticPath, type: 'static', exists: true }); + } + } + + return execs; + } + + async readRegistryValue (text: string) + { + const params = text.split('|'); + const key = path.dirname(params[0]); + const value = path.basename(params[0]); + const bin = params.length > 1 ? params[1] : undefined; + + const proc = Bun.spawn({ + cmd: ["reg", "QUERY", key, "/v", value], + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (!output.includes(value)) return null; + + const lines = output.split("\n"); + for (const line of lines) + { + if (line.includes(value)) + { + const parts = line.trim().split(/\s{4,}/); + return bin ? path.join(parts[2], bin) : parts[2]; // registry value + } + } + + return null; + } + + async resolveStaticPath (entries: string[]) + { + for (const entry of entries) + { + const resolved = entry.replace("~", os.homedir()); + if (await fs.exists(resolved)) + { + return resolved; + } + } + return null; + } + + async resolveSystemPath (entries: string[]) + { + for (const entry of entries) + { + try + { + const found = which(entry); + return found; + } catch { } + } + return null; + } + + async findExecsByName (emulatorName: string) + { + const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) }); + if (!emulator) + { + throw new Error(`Could not find emulator ${emulatorName}`); + } + return this.findExecs(emulatorName, emulator); + } + + async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; }) + { + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (config.mainGlob) + { + const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath })); + if (files.length > 1) + { + throw new Error("Found multiple rom files"); + } else if (files.length === 0) + { + throw new Error("Found no valid roms"); + } + + validFiles.push(path.join(gamePath, files[0])); + } else + { + if (!config.systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } + } else if (config.systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; + } + + /** + * + * @param data Uses es-de system slug + * @param mainGlob The main file glob supported pattern to search for if game path is a directory + * @returns + */ + async getValidLaunchCommands (data: { + systemSlug: string; + gamePath: string; + mainGlob?: string | null; + }): Promise + { + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, data.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${data.systemSlug}'`); + } + + if (!system.extension || system.extension.length <= 0) + { + throw new Error(`No extensions listed for system '${data.systemSlug}'`); + } + + const downloadPath = config.get('downloadPath'); + const gamePath = path.join(downloadPath, data.gamePath); + + const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); + + function escapeWindowsArg (arg: string): string + { + if (process.platform === 'win32') + { + return `"${arg + .replace(/(\\*)"/g, '$1$1\\"') // escape quotes + .replace(/(\\*)$/, '$1$1') // escape trailing backslashes + }"`; + } else + { + if (arg.includes(' ')) + { + return `"${arg}"`; + } else + { + return arg; + } + } + } + + const formattedCommands = await Promise.all(system.commands + .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) + .map(async (command, index) => + { + const label = command.label; + let cmd = command.command; + + let emulator: string | undefined = undefined; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + + + const staticVars: Record = { + '%ROM%': escapeWindowsArg(rom), + '%ROMRAW%': validFiles[0], + '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), + '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), + '%ROMPATH%': escapeWindowsArg(gamePath), + '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), + '%ESCAPESPECIALS%': "", + '%HIDEWINDOW%': "" + }; + + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => + { + try + { + const resolvedInjectFile = injectFile.replace(this.varRegex, (a) => + { + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); + } + + return ''; + } catch (error) + { + return ''; + } + }); + + const matches = Array.from(cmd.matchAll(this.varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let execs = await this.findExecsByName(emulatorName); + let validExec = execs.find(e => e.exists); + + emulator = emulatorName; + return [ + [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], + [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], + ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], + ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] + ]; + + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]] as [string, string | undefined]]; + })); + + const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; + let startDir: string | undefined = undefined; + + if ('%STARTDIR%' in vars) + { + delete vars['%STARTDIR%']; + + cmd = cmd.replace(this.assignRegex, (match, p1, p2) => + { + if (p1 === '%STARTDIR%') + { + startDir = this.varRegex.test(p2) ? staticVars[p2] : p2; + } + return ""; + }); + } + + // missing variable + const invalid = Object.entries(vars).find(c => c[1] === undefined); + + const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim(); + + return { + id: index, + label: label ?? undefined, + command: formattedCommand, + startDir, + valid: !invalid, emulator, + emulatorSource: vars['%EMUSOURCE%'] as any, + metadata: { + romPath: validFiles[0], + emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], + emulatorDir: vars['%EMUDIRRAW%'] + } + } satisfies CommandEntry; + })); + + return formattedCommands.filter(c => !!c); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) => + { + sources.push(...await this.findExecsByName(emulator)); + }); + + ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) => + { + emulators.push(...await this.getEmulatorsForSystem(system)); + }); + + ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) => + { + const localGame = await db.query.games.findFirst({ + where: getLocalGameMatch(id, source), + columns: { path_fs: true, main_glob: true }, + with: { platform: { columns: { es_slug: true } } } + }); + + if (!localGame?.path_fs) + { + return; + } + + const downloadPath = config.get('downloadPath'); + const path_fs = path.join(downloadPath, localGame.path_fs); + + return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) => + { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) }); + const allExecs = await this.findExecs(id.id, esEmulator); + return allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id.id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)); + } + + const rommPlatform = systemSlug; + let esSystem: string | undefined = undefined; + const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (systemMapping) esSystem = systemMapping.system; + + if (!esSystem) + { + const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } }); + if (system) esSystem = system.name; + } + + if (esSystem && gamePath) + { + try + { + const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob }); + + if (cores[esSystem]) + { + const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`; + commands.push({ + id: 'EMULATORJS', + label: "Emulator JS", + command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`, + valid: true, + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } + }); + } + + return commands; + } catch (error) + { + console.error(error); + if (error instanceof Error) return error; + } + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json new file mode 100644 index 0000000..9f4d82d --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.es", + "displayName": "ES-DE Launcher", + "version": "0.0.1", + "description": "ES-DE Launch Configurations. Used as fallback", + "main": "./es-de.ts", + "icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png", + "category": "launchers", + "keywords": [ + "integration", + "es-de" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json new file mode 100644 index 0000000..20a8525 --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.rclone", + "displayName": "Rclone Integration", + "version": "0.0.1", + "description": "Rclone integration for syncing saves", + "main": "./rclone.ts", + "icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png", + "category": "saves", + "keywords": [ + "integration", + "rclone" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts new file mode 100644 index 0000000..5c70f3b --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -0,0 +1,292 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, events } from "@/bun/api/app"; +import path, { dirname } from 'node:path'; +import unzip from 'unzip-stream'; +import { ensureDir } from "fs-extra"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import fs from 'node:fs/promises'; +import { randomUUIDv7, sleep } from "bun"; +import z from "zod"; +import { createInterface } from "node:readline"; +import { redirect } from "elysia"; +import { getErrorMessage } from "@/bun/utils"; +import { id } from "zod/v4/locales"; + +const SettingsSchema = z.object({ + runWebGui: z.boolean() + .default(false) + .describe("Run the Web GUI that can be accessed at http://localhost:5572") + .meta({ title: "Run Web GUI" }), + globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), + webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), + remoteName: z.string().default(""), + verboseLog: z.boolean() + .default(false) + .describe("Show detailed log of operation for debugging") + .meta({ $comment: JSON.stringify({ category: "debug" }) }) +}); + +type SettingsType = z.infer; +const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm; + +export default class RcloneIntegration implements PluginType +{ + settingsSchema = SettingsSchema; + rclonePath: string | undefined; + server: Bun.Subprocess | undefined; + password: string; + user = "gameflow"; + loginUrl: string | undefined = undefined; + eventsNames = [{ + id: "open-web-gui", + title: "Open Web GUI", + description: "Open Web GUI", + action: "Open" + }, { + id: "refresh", + title: "Refresh Sources", + action: "Refresh" + }]; + + constructor() + { + this.password = randomUUIDv7(); + } + + async onEvent (id: string) + { + switch (id) + { + case "open-web-gui": + return { openTab: this.loginUrl }; + break; + case "refresh": + await this.refresh(); + return { reload: true }; + break; + } + } + + async setup (ctx: PluginLoadingContextType) + { + ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true }); + ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); + + const toolsPath = path.join(config.get('downloadPath'), "tools"); + const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (existingRclones[0]) + { + this.rclonePath = path.join(toolsPath, existingRclones[0]); + await this.startServer(ctx); + return; + } + + if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64'))) + { + return; + } + + ctx.setProgress(0.5, "Downloading RClone"); + const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); + + await ensureDir(toolsPath); + await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); + const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (dests[0]) + { + this.rclonePath = path.join(toolsPath, dests[0]); + await this.startServer(ctx); + return; + } + } + + async refresh () + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" }); + } + + async startServer (ctx: PluginLoadingContextType) + { + const args: string[] = []; + if (ctx.config.get('runWebGui')) + { + args.push("--rc-web-gui"); + args.push("--rc-web-gui-no-open-browser"); + } + if (ctx.config.get('')) + { + args.push('-vv'); + } + let env: Record | undefined = undefined; + if (!ctx.config.get('globalConfig')) + { + env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') }; + } + ctx.config.set('webGuiPassword', this.password); + this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], { + stdout: "pipe", + stderr: "pipe", + env + }); + const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); + rl.on('line', e => + { + const data = JSON.parse(e); + + if (data.level === 'error') + { + console.error(data.msg); + } else + { + console.log(e); + if (loginTokenUrlRegex.test(data.msg)) + { + this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); + } + } + + }); + + await new Promise((resolve) => + { + const handleResolve = (line: string) => + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + }; + rl.on('line', handleResolve); + }); + + await this.refresh(); + } + + async request (path: string, body: any) + { + const response = await fetch(`http://localhost:5572${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}` + }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + if (response.ok) + { + return data; + } else + { + throw new Error(response.statusText, { cause: data }); + } + } + + async cleanup () + { + await this.request('/core/quit', {}).catch(e => + { + this.server?.kill("SIGKILL"); + }); + + await this.server?.exited; + } + + async load (ctx: PluginLoadingContextType) + { + await this.setup(ctx); + + ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => + { + if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) + { + + let src: string; + if (ctx.config.get('remoteName')) + { + src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + + const exists = await this.request('/operations/stat', { + fs: `${ctx.config.get('remoteName')}:`, + remote: `gameflow/saves/${source}/${id}/${slot}` + }).catch(e => undefined); + if (!exists || !exists.item) return; + + } else + { + src = path.join(config.get('downloadPath'), 'saves', source, id, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return; + } + + setProgress(0.5, "RClone: Syncing Saves"); + + const data = await this.request('/sync/copy', { + srcFs: src, + dstFs: cwd, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true, + DryRun: true + } + }); + console.log(data); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => + { + if (source !== 'store' || !this.rclonePath) return; + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let dest: string; + if (ctx.config.get('remoteName')) + { + dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + } else + { + dest = path.join(config.get('downloadPath'), 'saves', source, id, slot); + } + + const data = await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true + }, + _filter: { + IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/') + } + }).catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + + if (data) + { + events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' }); + } + })); + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts new file mode 100644 index 0000000..78d28b3 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -0,0 +1,83 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import secrets from "@/bun/api/secrets"; +import PQueue from 'p-queue'; +import * as igdb from '@phalcode/ts-igdb-client'; + +export default class IgdbIntegration implements PluginType +{ + queue: PQueue; + + constructor() + { + this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true }); + } + + async apiCall (subPath: string, query: string) + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + const headers = new Headers({ + "Client-ID": process.env.TWITCH_CLIENT_ID ?? '', + Authorization: `Bearer ${access_token}`, + Accept: "application/json" + }); + const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, { + headers: headers, + method: "POST", + body: query + })); + if (response.ok) + { + return response.json() as T; + } + } + + async cleanup () + { + this.queue.clear(); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => + { + if (!process.env.TWITCH_CLIENT_ID) return; + if (source !== 'igdb') return; + + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (access_token) + { + const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); + const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute(); + return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) }; + } + }); + + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => + { + let query: string | undefined = undefined; + if (source && id) + { + if (source !== 'igdb') return; + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`; + + } + else if (slug) + { + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`; + } + + if (query) + { + const data = await this.apiCall<[any]>('/platforms', query); + if (!data || data.length <= 0) return; + return { + slug: data[0].slug, + url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`, + name: data[0].name, + family_name: data[0].platform_family?.name + }; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json new file mode 100644 index 0000000..b1cd2e8 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.igdb", + "displayName": "IGDB Integration", + "version": "0.0.1", + "description": "IGDB Metadata Integration", + "main": "./igdb.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png", + "category": "sources", + "keywords": [ + "integration", + "igdb" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json index 815ddb0..52c2376 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json @@ -5,6 +5,7 @@ "description": "ROMM Server Integration", "main": "./romm.ts", "icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg", + "category": "sources", "keywords": [ "integration", "romm" diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 6193254..6bfbe2d 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -1,8 +1,8 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -12,9 +12,17 @@ import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; +import z from "zod"; -export default class RommIntegration implements PluginType +const SettingsSchema = z.object({ + savesSync: z.boolean().default(false).describe("Experimental save sync support") +}); + +type SettingsType = z.infer; + +export default class RommIntegration implements PluginType { + settingsSchema = SettingsSchema; isSteamDeck = false; orderByMap: Record = { added: "created_at", @@ -54,7 +62,7 @@ export default class RommIntegration implements PluginType { const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, + path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`], last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), metadata: { @@ -83,8 +91,8 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined, + igdb_id: rom.igdb_id, + ra_id: rom.ra_id, metadata: { age_ratings: rom.metadatum.age_ratings, genres: rom.metadatum.genres, @@ -126,15 +134,12 @@ export default class RommIntegration implements PluginType return detailed; } - async setup () + async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); await this.updateClient(); - } - load (ctx: PluginContextType) - { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { @@ -146,7 +151,7 @@ export default class RommIntegration implements PluginType limit: query.limit, offset: query.offset, order_by: this.orderByMap[query.orderBy ?? ''], - with_filter_values: true, + with_filter_values: false, genres: query.genres, genres_logic: "all", age_ratings: query.age_ratings, @@ -154,12 +159,6 @@ export default class RommIntegration implements PluginType }, throwOnError: true }); - rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); - rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); - rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); - rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); - rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); - games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -172,8 +171,10 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { + if (source && source !== 'romm') return; + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); rommFilters.data.companies.forEach(r => filters.companies.add(r)); @@ -188,7 +189,7 @@ export default class RommIntegration implements PluginType await this.updateClient(); }); - ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => { if (source !== 'romm') return; @@ -196,13 +197,6 @@ export default class RommIntegration implements PluginType if (rom.data) { const romGame = await this.convertRomToFrontendDetailed(rom.data); - if (localGame) - { - return { - ...romGame, - ...localGame, - }; - } return romGame; } @@ -405,10 +399,12 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => { - if (source !== 'romm') return; - if (saveFolderPath) + if (source !== 'romm' || !ctx.config.get('savesSync')) return; + if (!saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { setProgress(0, "saves"); @@ -418,53 +414,38 @@ export default class RommIntegration implements PluginType console.error(saveFiles.error); } else { - for (let i = 0; i < saveFiles.data.slots.length; i++) + const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot); + if (rommSlot) { - const slot = saveFiles.data.slots[i]; - const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); - if (await fs.exists(savePath)) - { - const existingSaveSync = await fs.stat(savePath); - const updatedAtTime = new Date(slot.latest.updated_at).getTime(); - - if (existingSaveSync.mtimeMs > updatedAtTime) - { - console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); - // Newer file - continue; - } else if (updatedAtTime === existingSaveSync.mtimeMs) - { - //TODO: do checksum comparison when that works on romm - console.log("Same save file", savePath); - continue; - } - } - const auth = await this.getAuthToken(); const headers: Record = {}; if (auth) headers['Authorization'] = auth; - const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers }); if (!saveResponse.ok) { console.error("Error downloading save", saveResponse.statusText); - break; + return; } - await Bun.write(savePath, saveResponse); - console.log("Loaded", savePath); - setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + + const saveArchive = new Bun.Archive(await saveResponse.blob()); + setProgress(50, "saves"); + const count = await saveArchive.extract(cwd); + setProgress(100, "saves"); + console.log("Loaded", count, "save files"); } } - setProgress(1, "saves"); + setProgress(100, "saves"); await Bun.sleep(1000); } }); - ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + // Should run after emulators decide on saves + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => { - if (source !== 'romm') return; + if (source !== 'romm' || !ctx.config.get('savesSync')) return; const sourceValidation = await validateGameSource(source, id); if (!sourceValidation.valid) @@ -473,7 +454,7 @@ export default class RommIntegration implements PluginType return; } - const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + /*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]); const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); if (saveFiles.error) @@ -494,29 +475,31 @@ export default class RommIntegration implements PluginType if (!finalSavePaths.some(f => f.subPath === subPath)) { // Add newer files to the list, maybe they were changed offscreen. - finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + finalSavePaths.push({ subPath, cwd: saveFolderPath }); } } } } - } + }*/ + + const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared); if (finalSavePaths.length > 0) { - console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", ")); - await Promise.all(finalSavePaths.map(async f => + await Promise.all(finalSavePaths.map(async ([slot, change]) => { - const absolutePath = path.join(f.cwd, f.subPath); - if (!await fs.exists(absolutePath)) return; - const stat = await fs.stat(absolutePath); - if (stat.isDirectory()) return; + const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath]; + + // TODO: handle directories + const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))]))); const data: FormData = new FormData(); - data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + data.append('saveFile', await archive.blob(), slot); const url = new URL(`${config.get('rommAddress')}/api/saves`); url.searchParams.set('rom_id', id); - url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('slot', slot); url.searchParams.set('autocleanup', "true"); url.searchParams.set('autocleanup_limit', "2"); if (command.emulator) @@ -582,11 +565,24 @@ export default class RommIntegration implements PluginType }); - ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { - if (source !== 'romm') return; - const platforms = await this.getAllRommPlatforms(); - return platforms.find(p => p.id === Number(id)); + let platform: PlatformSchema | undefined = undefined; + + if (id && source) + { + if (source !== 'romm') return; + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.id === Number(id)); + + } else if (slug) + { + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.slug === slug); + } + + if (!platform) return; + return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined }; }); ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json new file mode 100644 index 0000000..713f76f --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.store", + "displayName": "Gameflow Store", + "version": "0.0.1", + "description": "The internal gameflow store", + "main": "./store.ts", + "category": "sources", + "canDisable": false, + "keywords": [ + "internal", + "store" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts new file mode 100644 index 0000000..59e0432 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -0,0 +1,313 @@ +import { getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; +import os from 'node:os'; +import path from "node:path"; +import * as appSchema from '@schema/app'; +import * as emulatorSchema from '@schema/emulators'; +import { db, emulatorsDb, plugins } from "@/bun/api/app"; +import { and, eq } from "drizzle-orm"; +import { getOrCached } from "@/bun/api/cache"; +import { Glob } from "bun"; +import { shuffleInPlace } from "@/bun/utils"; +import mustache from "mustache"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import fs from "node:fs/promises"; + +export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) +{ + const offset = filter?.offset ?? 0; + const limit = Math.min(50, filter?.limit ?? 10); + + const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => + { + return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); + })); + + return games; +} + +export async function getStoreGame (id: string) +{ + const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`)); + if (!(await file.exists())) return undefined; + const game = file + .json() + .then(g => StoreGameSchema.parseAsync(g)) + .then(g => ({ ...g, id })); + return game; +} + +function convertStoreMediaToPath (c: string) +{ + if (c.startsWith('http')) + { + return `/api/romm/image?url=${encodeURIComponent(c)}`; + } else + { + return `/api/store/media/${c}`; + } +} + +export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + + let platform_slug: string | null = null; + let platform_id: number | null = null; + let platform_display_name: string | null = null; + let path_platform_cover: string | null = null; + + if (validDownload?.system) + { + let system = validDownload.system.split(':')[0]; + if (system === 'win32') system = 'win'; + + const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); + if (localPlatform) + { + platform_id = localPlatform.id; + platform_slug = localPlatform.slug; + path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`; + platform_display_name = localPlatform.name; + } + + if (platform_slug === null) + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (rommSystem?.system) + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, rommSystem?.system), + columns: { fullname: true } + }); + + platform_slug = rommSystem.system; + platform_display_name = platformDef?.fullname ?? null; + path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`; + + } else + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, system), + columns: { fullname: true } + }); + + platform_slug = system; + platform_display_name = platformDef?.fullname ?? null; + } + + platform_slug ??= system; + } + } + + + const game: FrontEndGameType = { + platform_display_name, + path_platform_cover, + id: { source: 'store', id: id }, + source: null, + source_id: null, + path_fs: null, + path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [], + last_played: null, + updated_at: new Date(), + slug: id, + name: storeGame.name, + platform_id, + platform_slug, + paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return game; +} + + +export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + let size: number | null = null; + if (validDownload?.url) + { + try + { + const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + } + + const detailed: FrontEndGameTypeDetailed = { + ...await convertStoreToFrontend(id, storeGame), + summary: storeGame.description, + fs_size_bytes: size, + missing: false, + local: false, + version: storeGame.version, + igdb_id: storeGame.igdb_id ?? null, + ra_id: storeGame.ra_id ?? null, + metadata: { + genres: storeGame.genres ?? [], + companies: storeGame.companies ?? [], + game_modes: [], + age_ratings: [], + player_count: storeGame.player_count ?? null, + average_rating: null, + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return detailed; +} + +export function getValidDownload (game: StoreGameType, downloadId?: string) +{ + const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); + const supportedDownloads = downloads.filter(d => d.type === 'direct'); + + if (downloadId) + { + return supportedDownloads.find(d => d.id === downloadId); + } else + { + return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) + ?? supportedDownloads.find(d => + { + // Linux supports proton, can run windows games + if (process.platform === 'linux') return d.system === `win32:${process.arch}`; + return false; + }) + // Fallback to emulator platforms + ?? supportedDownloads.find(d => !d.system.includes(':')); + } +} + +export async function getShuffledStoreGames () +{ + return getOrCached('shuffled-store-games', async () => + { + const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan(); + const allGamePaths = await Array.fromAsync(files); + const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') })))); + shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); + return allStoreGames; + }, { expireMs: 1000 / 60 / 60 }); +} + +export async function buildFilters (filters: FrontEndFilterSets) +{ + const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json')); + if (!await filtersFile.exists()) return; + const storeFilters = await filtersFile.json(); + + storeFilters.genres?.forEach((g: string) => filters.genres.add(g)); + storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g)); + if (storeFilters.player_count) + filters.player_counts.add(storeFilters.player_count); + storeFilters.companies?.forEach((g: string) => filters.companies.add(g)); +} + +function getAppData () +{ + if (process.platform === "win32") return process.env.APPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support"); + // linux + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function getLocalAppData () +{ + if (process.platform === "win32") return process.env.LOCALAPPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches"); + // Linux / Unix + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); +} + +export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType) +{ + let saveFileGlobs: Record | undefined = undefined; + if (download && download.saves) + { + saveFileGlobs = download.saves; + + } else if (storeGame.saves) + { + const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`]; + if (platformSaves) + { + saveFileGlobs = platformSaves; + } + } + + const view = { + GAMEDIR: command.startDir, + HOMEDIR: os.homedir(), + TMPDIR: os.tmpdir(), + APPDATA: getAppData(), + LOCALAPPDATA: getLocalAppData(), + }; + + if (!saveFileGlobs) return; + + return Object.entries(saveFileGlobs).map(([slot, save]) => + { + const cwd = mustache.render(save.cwd, view); + const change: SaveFileChange = { + cwd, + shared: false, + isGlob: true, + subPath: save.globs + }; + return [slot, change] as [string, SaveFileChange]; + }); +} + +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[]) +{ + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths }); + + const em: FrontEndEmulator = { + name: emulator.name, + logo: emulator.logo, + systems, + gameCount: 0, + validSources: execPaths, + integrations: [] + }; + + return em; +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts new file mode 100644 index 0000000..e3dec17 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -0,0 +1,312 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import path, { basename, dirname } from 'node:path'; +import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { Glob, pathToFileURL } from "bun"; +import { getOrCached } from "@/bun/api/cache"; +import { shuffleInPlace } from "@/bun/utils"; +import { and, eq } from "drizzle-orm"; +import * as emulatorSchema from '@schema/emulators'; + +import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app"; +import fs from "node:fs/promises"; +import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; +import mustache from "mustache"; +import os from 'node:os'; +import UpdateStoreJob from "@/bun/api/jobs/update-store"; +import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services"; + +export default class RommIntegration implements PluginType +{ + async setup (ctx: PluginLoadingContextType) + { + console.log("Store Directory is ", getStoreFolder()); + ctx.setProgress(0, "Updating Store"); + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + } + + async load (ctx: PluginLoadingContextType) + { + + ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + return downloadInfo; + }); + + ctx.hooks.store.fetchEmulator.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return undefined; + + const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); + + const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); + const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; + const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); + const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + + const emulator: FrontEndEmulatorDetailed = { + name: emulatorPackage.name, + description: emulatorPackage.description, + source: "store", + systems, + validSources: [], + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), + gameCount: 0, + homepage: emulatorPackage.homepage, + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + { + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), + logo: emulatorPackage.logo, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integrations: [], + storeDownloadInfo: storeDownloadInfo + }; + + return emulator; + }); + + ctx.hooks.store.fetchEmulators.tapPromise(desc.name, async ({ emulators, search }) => + { + const emulatesParsed = await getAllStoreEmulatorPackages(); + emulators.push(...await Promise.all(emulatesParsed + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (search) + { + if (e.name.toLocaleLowerCase().includes(search) || e.systems.some(s => s.toLocaleLowerCase().includes(search)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(search))) + { + return true; + } + + return false; + } + return true; + }) + .map(async (emulator) => + { + const systems = await buildStoreFrontendEmulatorSystems(emulator); + return convertStoreEmulatorToFrontend(emulator, systems); + }))); + }); + + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + const saves = buildSaves(command, storeGame, download); + + saves?.forEach(([slot, save]) => saveFolderSlots[slot] = { cwd: save.cwd }); + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ validChangedSaveFiles, source, id, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + + const saves = buildSaves(command, storeGame, download); + saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => + { + if (source !== 'store' || !gamePath || systemSlug !== 'win') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + const fileGlob = new Glob(mainGlob ?? '**/*.exe'); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) + { + return [{ + startDir: path.join(downloadPath, gamePath, dirname(file)), + command: basename(file), + id: 'store-win', + valid: true, + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + metadata: { + romPath: path.join(downloadPath, gamePath, file) + } + }]; + } + + } else + { + return [{ + startDir: path.join(downloadPath, dirname(gamePath)), + command: basename(gamePath), + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + id: 'store-win', + valid: true, + metadata: { + romPath: path.join(downloadPath, gamePath) + } + }]; + } + + }); + + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => + { + if (!source || source !== 'store') return; + await buildFilters(filters); + }); + + ctx.hooks.store.fetchFeaturedGames.tapPromise(desc.name, async ({ games }) => + { + const allGames = await getShuffledStoreGames(); + const convertedGames = await Promise.all(allGames.slice(0, 3).map(async g => + { + return convertStoreToFrontendDetailed(g.id, g); + })); + games.push(...convertedGames); + }); + + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + { + if (!query.source || query.source !== 'store') return; + if (query.collection_source || query.collection_id) return; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.name).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) + .map(async (e) => + { + const game: FrontEndGameTypeWithIds = { + ...await convertStoreToFrontend(e.id, e), + igdb_id: e.igdb_id ?? null, + ra_id: e.ra_id ?? null + }; + return game; + })); + games.push(...storeGames.filter(g => g !== undefined)); + }); + + ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => + { + const esSystem = game.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, game.platform_slug)), columns: { system: true } }) : undefined; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames + .filter(g => + { + if (esSystem) + { + if (Object.values(g.downloads).some(d => d.system === esSystem.system)) return true; + } + + return false; + }) + .map(async (e) => + { + return convertStoreToFrontend(e.id, e); + })); + + if (storeGames) + { + games.push(...storeGames.slice(0, 3)); + } + }); + + ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => + { + const systemsIdSet = new Set(systems.map(s => s.id)); + const gamesManifest = await getShuffledStoreGames(); + const storeGames = await Promise.all(gamesManifest + .filter(g => Object.values(g.downloads).some(d => systemsIdSet.has(d.system))) + .map(async (e) => + { + + return convertStoreToFrontend(e.id, e); + })); + + games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + if (storeGame) + { + return convertStoreToFrontendDetailed(id, storeGame); + } + }); + + ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id, downloadId }) => + { + if (source !== 'store') return; + const game = await getStoreGame(id); + if (!game) throw new Error("Missing Store Game"); + + const validDownload = getValidDownload(game, downloadId); + + if (validDownload) + { + let system = validDownload.system.split(":")[0]; + if (system === 'win32') system = 'win'; + + const info: DownloadInfo = { + coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + screenshotUrls: game.screenshots ?? [], + files: [{ + url: new URL(validDownload.url), + file_path: `roms/${system}`, + file_name: path.basename(decodeURI(validDownload.url)), + size: 0 + }], + slug: id, + source_id: id, + name: game.name, + summary: game.description, + system_slug: system, + path_fs: path.join('roms', system, game.id), + extract_path: '.', + main_glob: validDownload.main, + version: game.version, + version_system: validDownload.system, + version_source: validDownload.id, + platform: { + slug: system, + name: system + } + }; + + return info; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index 90392f1..e22b2c2 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,6 +1,15 @@ import { GameflowHooks } from "../hooks/app"; -import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/typesc.schema"; import { config } from "../app"; +import Conf from "conf"; +import projectPackage from '~/package.json'; +import z from "zod"; +import { EventEmitter } from "node:stream"; + +export const pluginZodRegistry = z.registry<{ + requiresRestart?: boolean; + readOnly?: boolean; +}>(); export class PluginManager { @@ -11,10 +20,11 @@ export class PluginManager plugin: PluginType; description: PluginDescriptionType, source: PluginSourceType; + config?: Conf; }> = {}; - async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try { @@ -24,15 +34,29 @@ export class PluginManager } else { - if (plugin.setup) await plugin.setup(); + let pluginConfig: Conf | undefined = undefined; + if (plugin.settingsSchema) + { + pluginConfig = new Conf({ + projectName: projectPackage.name, + configName: description.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + schema: Object.fromEntries(Object.entries(plugin.settingsSchema.shape).map(([key, schema]) => [key, (schema as z.ZodObject).toJSONSchema() as any])) as any, + defaults: plugin.settingsSchema.parse({}), + migrations: plugin.settingsMigrations as any, + projectVersion: description.version + }); + } + this.plugins[description.name] = { enabled: !config.get('disabledPlugins').includes(description.name), loaded: false, plugin: plugin, source: source, - description: description + description: description, + config: pluginConfig }; - this.reload(description.name); console.log("Plugin", description.name, "registered"); } @@ -44,24 +68,29 @@ export class PluginManager }; } - private reload (name: string) + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }) { const plugin = this.plugins[name]; if (plugin) { - const ctx: PluginContextType = { hooks: this.hooks }; + const ctx: PluginLoadingContextType = { + hooks: this.hooks, + setProgress: reloadCtx.setProgress.bind(reloadCtx), + config: plugin.config as any, + zodRegistry: pluginZodRegistry + }; if (plugin.loaded) { - plugin.plugin.onBeforeReload?.(ctx); + await plugin.plugin.cleanup?.(); plugin.loaded = false; } try { - if (plugin.enabled) + if (plugin.enabled || plugin.description.canDisable === false) { - plugin.plugin.load(ctx); + await plugin.plugin.load(ctx); plugin.loaded = true; } } catch (error) @@ -72,10 +101,14 @@ export class PluginManager } } - reloadAll () + async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); - Object.keys(this.plugins).forEach(id => this.reload(id)); + for await (const id of Object.keys(this.plugins)) + { + ctx.setProgress(0, `Loading ${id}`); + await this.reload(id, ctx); + } } async cleanup () @@ -84,7 +117,10 @@ export class PluginManager { try { - await p.plugin.cleanup!(); + if (p.loaded) + { + await p.plugin.cleanup!(); + } } catch (error) { console.log("Error for plugin", p.description.name, "while cleaning up"); diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index e276f92..6a2dedc 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -1,7 +1,8 @@ import Elysia, { status } from "elysia"; -import { plugins } from "../app"; +import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -15,19 +16,31 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - icon: p.description.icon + canDisable: p.description.canDisable ?? true, + icon: p.description.icon, + category: p.description.category, + hasSettings: !!p.config }; return plugin; }); }) + .get('/:id', async ({ params: { id } }) => + { + const plugin = plugins.plugins[id]; + return plugin.description; + }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { const plugin = plugins.plugins[id]; if (plugin) { + if (plugin.description.canDisable === false) + { + return status("Forbidden"); + } plugin.enabled = enabled; toggleElementInConfig('disabledPlugins', plugin.description.name, enabled); - plugins.reloadAll(); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); } else { return status("Not Found"); diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 3c49311..5f542ae 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -7,11 +7,14 @@ import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.j import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; +import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.json'; +import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; +import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; +import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; export default async function register (pluginManager: PluginManager) { - const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, @@ -20,9 +23,24 @@ export default async function register (pluginManager: PluginManager) { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, + { ...igdb, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.igdb/igdb') }, + { ...es, load: () => import('./builtin/launchers/com.simeonradivoev.gameflow.es/es-de') }, + { ...store, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.store/store') }, + { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - await Promise.all(plugins.map(async (pluginPackage) => + await Promise.all(plugins.filter(p => + { + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name)) + { + return false; + } + if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name)) + { + return false; + } + return true; + }).map(async (pluginPackage) => { const file = await pluginPackage.load(); if (file.default && typeof file.default === 'function') diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index 35c9c5a..2226b20 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -9,6 +9,7 @@ export const games = sqliteTable('games', { name: text("name"), ra_id: integer('ra_id').unique(), path_fs: text("path_fs"), + main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ @@ -24,7 +25,10 @@ export const games = sqliteTable('games', { platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), cover_type: text('type'), - summary: text("summary") + summary: text("summary"), + version: text('version'), + version_source: text("version_source"), + version_system: text("version_system"), }); export const gamesRelations = relations(games, ({ many, one }) => ({ diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 7b7a89e..ea76797 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,10 +2,9 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { db, emulatorsDb } from '../app'; +import { db, emulatorsDb, plugins } from '../app'; import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; -import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; @@ -54,7 +53,18 @@ export async function getRelevantEmulators () const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { - const execPaths = await findExecsByName(emulator); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(emulator, execPaths); + + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return storeEmulator; + } let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -75,7 +85,7 @@ export async function getRelevantEmulators () gameCount: 0, isCritical: false, validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator, execPaths) + integrations }; return em; diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index dda53b9..c315701 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,12 +1,15 @@ import z from "zod"; import { SettingsSchema } from "@shared/constants"; import Elysia, { status } from "elysia"; -import { config, customEmulators, taskQueue } from "../app"; +import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; import { existsSync } from "node:fs"; import { InstallJob } from "../jobs/install-job"; import { move } from "fs-extra"; import { getRelevantEmulators } from "./services"; +import type { JSONSchema7 } from "json-schema"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; +import { pluginZodRegistry } from "../plugins/plugin-manager"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -77,18 +80,59 @@ export const settings = new Elysia({ prefix: '/api/settings' }) drive: z.string().optional() }) }) - .get("/:id", async ({ params: { id } }) => + .get("local/:id", async ({ params: { id } }) => { const value = config.get(id); return { value: value }; }, { params: z.object({ id: z.keyof(SettingsSchema) }), - }).post('/:id', + }).post('local/:id', async ({ params: { id }, body: { value }, }) => { config.set(id, value); }, { params: z.object({ id: z.keyof(SettingsSchema) }), body: z.object({ value: z.any() }), - }); + }) + .get('/definitions/:source', async ({ params: { source } }) => + { + return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + }) + .get('/actions/:source', async ({ params: { source } }) => + { + const plugin = plugins.plugins[source]?.plugin; + if (!plugin.eventsNames) return []; + return plugin.eventsNames; + }) + .post('/actions/:source/:id', async ({ params: { source, id } }) => + { + return await plugins.plugins[source]?.plugin.onEvent?.(id); + }) + .get('/:source/:id', async ({ params: { source, id } }) => + { + return { value: plugins.plugins[source].config?.get(id) }; + }) + .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => + { + const plugin = plugins.plugins[source]; + if (!plugin.config) return status("Not Found", "Plugin has no config"); + const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject; + if (!settingSchema) return status("Not Found", "Could not find setting"); + const meta = pluginZodRegistry.get(settingSchema); + + if (meta?.readOnly) + { + return; + } + + plugin.config?.set(id, value); + + if (meta?.requiresRestart) + { + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + } + }, + { + body: z.object({ value: z.any() }) + }); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1682ecb..d29a1be 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,34 +1,7 @@ -import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; -import { config, emulatorsDb, plugins } from "../../app"; -import * as emulatorSchema from '@schema/emulators'; -import { findExecs } from "../../games/services/launchGameService"; -import { eq } from "drizzle-orm"; +import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; +import { config, plugins } from "../../app"; import { getOrCached, getOrCachedGithubRelease } from "../../cache"; import path from "node:path"; -import fs from "node:fs/promises"; - -export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) -{ - const execPaths: EmulatorSourceEntryType[] = []; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); - - if (esEmulator) - { - const allExecs = await findExecs(emulator.name, esEmulator); - execPaths.push(...allExecs); - } - - const em: FrontEndEmulator = { - name: emulator.name, - logo: emulator.logo, - systems, - gameCount, - validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator.name, execPaths) - }; - - return em; -} export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { @@ -52,29 +25,6 @@ export function getEmulatorPath (emulator: string) return path.join(config.get('downloadPath'), "emulators", emulator); } -export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> -{ - const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; - if (await fs.exists(existingPackagePath)) - { - const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); - const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); - if (!download) return { ...existingPackage, hasUpdate: false }; - if (download.info.version) - { - if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; - } else if (existingPackage.id !== download.info.id) - { - return { ...existingPackage, hasUpdate: true }; - } - - return { ...existingPackage, hasUpdate: false }; - } - - // this should only happen if download info is missing maybe manually deleted or wasn't saved. - return undefined; -} - export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) { if (!emulator.downloads) throw new Error("Emulator has no downloads"); diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 99aa15a..17ee6ec 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -6,74 +6,9 @@ import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; import { shuffleInPlace } from "@/bun/utils"; +import { Glob } from "bun"; -export async function getShuffledStoreGames () -{ - return getOrCached('shuffled-store-games', async () => - { - const gamesManifest = await getStoreGameManifest(); - const allStoreGames = gamesManifest.filter(g => g.type === 'blob'); - shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); - return allStoreGames; - }, { expireMs: 1000 / 60 / 60 }); -} -export async function getStoreGameManifest () -{ - return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () => - { - const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data)); - - return store.tree.filter((e: any) => - { - if (e.type === 'blob' && e.path !== "featured.json") - { - return true; - } - return false; - }); - }); -} - -export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) -{ - const offset = filter?.offset ?? 0; - const limit = Math.min(50, filter?.limit ?? 10); - - const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => - { - return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); - })); - - return games; -} - -export function extractStoreGameSourceId (id: string) -{ - const gameId = id.split('@'); - if (gameId.length !== 2) - throw new Error("Store ID should include platform and name with @ separator"); - return { system: gameId[0], id: gameId[1] }; -} - -export function getStoreGameFromId (id: string) -{ - const data = extractStoreGameSourceId(id); - return getStoreGame(data.system, data.id); -} - -export async function getStoreGame (system: string, id: string) -{ - return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`); -} - -export async function getStoreGameFromPath (path: string) -{ - const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`) - .then(e => e.json()) - .then(g => StoreGameSchema.parseAsync(g))); - return game; -} export function getStoreRootFolder () { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 5145232..b15f5b6 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -1,19 +1,18 @@ import Elysia, { status } from "elysia"; -import { config, db, taskQueue } from "../app"; +import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants"; -import { findExecsByName } from "../games/services/launchGameService"; +import { EmulatorDownloadInfoSchema } from "@/shared/constants"; import * as appSchema from '@schema/app'; import z from "zod"; -import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; +import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; import { CACHE_KEYS, getOrCached } from "../cache"; -import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; +import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; +import { findEmulatorPluginIntegration } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -23,42 +22,32 @@ export const store = new Elysia({ prefix: '/api/store' }) console.error(e); return undefined; }); - const emulatesParsed = await getAllStoreEmulatorPackages(); - let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => + + + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search }); + + await Promise.all(frontEndEmulators.map(async e => + { + const gameCounts = e.systems.map((s) => { - if (!e.os.includes(process.platform as any)) return false; - if (query.search) + const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); + if (romPlatform) { - const lowerCaseSearch = query.search.toLocaleLowerCase(); - - if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) - { - return true; - } - - return false; + return romPlatform.rom_count; } - return true; - }) - .map(async (emulator) => - { - const systems = await buildStoreFrontendEmulatorSystems(emulator); - const gameCounts = await Promise.all(systems.map(async (s) => - { - const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); - if (romPlatform) - { - return romPlatform.rom_count; - } - return 0; + return 0; - })); + }); - const gameCount = gameCounts.reduce((a, c) => a + c); - return convertStoreEmulatorToFrontend(emulator, gameCount, systems); - })); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: e.name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(e.name, execPaths); + + e.gameCount = gameCounts.reduce((a, c) => a + c); + e.integrations = integrations; + })); if (query.missing) { @@ -98,25 +87,31 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .get('/games/featured', async () => { - const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json'); - const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json()); - return Promise.all(games.featured.map(async g => + const games: FrontEndGameTypeDetailed[] = []; + await plugins.hooks.store.fetchFeaturedGames.promise({ games }); + + return Promise.all(games.map(async g => { - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') }); + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(g.id.id, g.id.source) }); if (localGame) return convertLocalToFrontendDetailed(localGame); - return convertStoreToFrontendDetailed(g.system, g.title, g); + return g; })); }) .get('/stats', async () => { - const emulatesParsed = await getAllStoreEmulatorPackages(); - const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length; + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators }); + const storeEmulatorCount = frontEndEmulators.length; const gameCount = await db.$count(appSchema.games); return { storeEmulatorCount, gameCount }; }) + .get('/media/*', async ({ params }) => + { + return Bun.file(path.join(getStoreFolder(), params["*"])); + }) .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => { return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); @@ -124,49 +119,14 @@ export const store = new Elysia({ prefix: '/api/store' }) { params: z.object({ id: z.string(), name: z.string() }) }) .get('/emulator/:id/update', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); - return downloadInfo; + return plugins.hooks.store.fetchDownload.promise({ id }); }, { response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) }) .get('/emulator/:id', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - if (!emulatorPackage) return status("Not Found"); - - const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); - - const execPaths = await findExecsByName(emulatorPackage.name); - - const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); - const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); - const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; - const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); - - const emulator: FrontEndEmulatorDetailed = { - name: emulatorPackage.name, - description: emulatorPackage.description, - systems, - validSources: execPaths, - screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), - gameCount: 0, - homepage: emulatorPackage.homepage, - downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => - { - const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); - return download?.info; - }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), - logo: emulatorPackage.logo, - biosRequirement: emulatorPackage.bios, - bios: biosFiles, - integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo - }; - - return emulator; + return plugins.hooks.store.fetchEmulator.promise({ id }); }, { params: z.object({ id: z.string() }) }) .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 720a10b..5292b85 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events } from "./app"; +import { cachePath, config, events, taskQueue } from "./app"; import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -12,6 +12,22 @@ import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; +import { semver } from "bun"; +import packageDef from '~/package.json'; + +async function checkUpdate () +{ + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + const hasUpdate = semver.order(data.tag_name, packageDef.version); + return hasUpdate; + } + + return 0; +} export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => @@ -60,29 +76,64 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers["cache-control"] = 'no-cache'; set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); + }) + .get('/notifications/all', ({ }) => + { + }) .ws('/info/system', { response: z.discriminatedUnion('type', [ z.object({ type: z.literal('info'), data: SystemInfoSchema }), - z.object({ type: z.literal('focus') }) + z.object({ type: z.literal('focus') }), + z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), + z.object({ type: z.literal('loaded') }), ]), async open (ws) { - const battery = await si.battery(); - const wifi = await si.wifiConnections(); - const bluetooth = await si.bluetoothDevices(); - ws.send({ - type: 'info', - data: { - battery: battery, - wifiConnections: wifi, - bluetoothDevices: bluetooth - } - }, true); + const existingLoading = taskQueue.findJob(ReloadPluginsJob.id, ReloadPluginsJob); + if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); + else ws.send({ type: 'loaded' }); + + const startInfo = async () => + { + const battery = await si.battery(); + const wifi = await si.wifiConnections(); + const bluetooth = await si.bluetoothDevices(); + ws.send({ + type: 'info', + data: { + battery: battery, + wifiConnections: wifi, + bluetoothDevices: bluetooth + } + }, true); + }; + startInfo(); const handleFocus = () => ws.send({ type: 'focus' }); events.on('focus', handleFocus); - (ws.data as any).dispose = [() => events.removeListener('focus', handleFocus)]; + const dispose: (() => void)[] = []; + + dispose.push(taskQueue.on('progress', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: e.progress, state: e.state }); + })); + dispose.push(taskQueue.on('started', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: 0 }); + })); + dispose.push(taskQueue.on('ended', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loaded" }); + })); + + (ws.data as any).dispose = [...dispose, () => + { + events.removeListener('focus', handleFocus); + }]; (ws.data as any).observer = setInterval(async () => { const battery = await si.battery(); @@ -209,4 +260,8 @@ export const system = new Elysia({ prefix: '/api/system' }) await openExternal(url); }, { body: z.object({ url: z.string() }) + }) + .get('/update', async () => + { + return checkUpdate(); }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 308f217..34459ed 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,7 +1,8 @@ +import { and } from 'drizzle-orm'; import EventEmitter from 'node:events'; -import z from 'zod'; +import z, { any } from 'zod'; export class TaskQueue { @@ -121,29 +122,29 @@ export interface EventsList queued: [e: BaseEvent]; } -interface BaseEvent +export interface BaseEvent { id: string; job: IPublicJob; } -interface ErrorEvent extends BaseEvent +export interface ErrorEvent extends BaseEvent { error: unknown; } -interface AbortEvent extends BaseEvent +export interface AbortEvent extends BaseEvent { reason?: any; } -interface ProgressEvent extends BaseEvent +export interface ProgressEvent extends BaseEvent { progress: number; state?: string; } -interface CompletedEvent extends BaseEvent +export interface CompletedEvent extends BaseEvent { } diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index aafc779..c87a6ef 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -1,28 +1,52 @@ import z from "zod"; import { GameflowHooks } from "../api/hooks/app"; +import Conf from "conf"; +import { $ZodRegistry } from "zod/v4/core"; +import EventEmitter from "node:events"; export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) }); +export const PluginLoadingContextSchema = z.object({ + setProgress: z.function().input([z.number(), z.string()]).output(z.void()), + config: z.instanceof(Conf), + zodRegistry: z.instanceof($ZodRegistry) +}).extend(PluginContextSchema.shape); + export const PluginDescriptionSchema = z.object({ name: z.string(), displayName: z.string(), version: z.string(), description: z.string(), icon: z.url().optional(), - keywords: z.array(z.string()).optional() + keywords: z.array(z.string()).optional(), + category: z.string().default("other"), + canDisable: z.boolean().default(true).optional() }); export const PluginSchema = z.object({ - setup: z.function().output(z.promise(z.void())).optional(), - load: z.function().input([PluginContextSchema]).output(z.void()), - onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(), - cleanup: z.function().output(z.promise(z.void())).optional() + load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())), + cleanup: z.function().output(z.promise(z.void())).optional(), + settingsSchema: z.instanceof(z.ZodObject).optional(), + settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(), + eventsNames: z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + action: z.string() + }).array().optional(), + onEvent: z.function().input([z.string()]).output(z.any()).optional() }); -export type PluginType = z.infer; +export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { + load: (ctx: PluginLoadingContextType) => Promise; + settingsMigrations?: Record) => void>; +}; export type PluginContextType = z.infer; +export type PluginLoadingContextType = Record> = z.infer & { + config: Conf; +}; export type PluginDescriptionType = z.infer; export const ActiveGameSchema = z.object({ diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 76dcda3..605f15d 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -9,7 +9,6 @@ export const focusQueue: string[] = []; export default function App (data: { children: any; }) { - useEffect(() => { const focusMap = new Map(); diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index f0ba0a3..b30ef41 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,12 +1,16 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; import { SystemInfoType } from "@/shared/constants"; +import LoadingScreen from "./LoadingScreen"; export default function AppCommunication (data: { children: any; }) { - const [systemInfo, setSystemInfo] = useState(); + const [loadingInfo, setLoadingInfo] = useState(undefined); + const [loading, setLoading] = useState(true); + const loadingProgressBarRef = useRef(null); + useEffect(() => { const sub = systemApi.api.system.info.system.subscribe(); @@ -20,14 +24,32 @@ export default function AppCommunication (data: { children: any; }) case "focus": window.focus(); break; + case "loading": + setLoadingInfo(data.state); + if (loadingProgressBarRef.current) + loadingProgressBarRef.current.value = data.progress; + setLoading(true); + break; + case "loaded": + setLoading(false); + break; } - }); document.documentElement.dataset.loaded = "true"; }, []); return - {data.children} + {loading ? + +
    +
    + + {loadingInfo} +
    + +
    +
    + : data.children}
    ; } \ No newline at end of file diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index fdbee68..6960315 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -4,6 +4,7 @@ import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import useActiveControl from "../scripts/gamepads"; import { oneShot } from "../scripts/audio/audio"; +import ImageWithFallbacks from "./ImageWithFallbacks"; export function GameCardSkeleton () { @@ -21,8 +22,8 @@ export function GameCardSkeleton () export interface GameCardParams extends FocusParams { title: string; - subtitle: string | JSX.Element; - preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + subtitle?: string | JSX.Element; + preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element); srcset?: string; focusKey: string; index: number; @@ -49,6 +50,21 @@ export default function CardElement (data: GameCardParams & InteractParams) }); const { isPointer } = useActiveControl(); + let preview: any = undefined; + if (typeof data.preview === "string") + { + preview = ; + } else if (Array.isArray(data.preview)) + { + preview = ; + } else if (typeof data.preview === 'function') + { + preview = data.preview({ focused }); + } else + { + preview = data.preview; + } + return (
  • - {typeof data.preview === "string" ? ( - - ) : ( - typeof data.preview === 'function' ? data.preview({ focused }) : data.preview - )} + {preview}
    diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 671018f..23ed5e7 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -20,9 +20,9 @@ export interface GameMetaExtra extends GameMeta function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) { let preview: GameCardParams['preview'] = data.game.preview; - if (!preview && data.game.previewUrl) + if (!preview && data.game.previewUrls) { - preview = data.game.previewUrl; + preview = data.game.previewUrls; } const handleAction = (ctx: InteractParamsArgs) => @@ -40,7 +40,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara focusKey={data.game.focusKey} data-index={data.i} title={data.game.title} - subtitle={data.game.subtitle ?? ""} + subtitle={data.game.subtitle} srcset={data.game.previewSrcset} onFocus={(focusKey, node, details) => { @@ -69,8 +69,6 @@ export function CardList (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id, - forceFocus: true, - autoRestoreFocus: true, focusable: data.games.length > 0, preferredChildFocusKey: data.focus }); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 121be98..c04db7f 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -37,7 +37,6 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - subtitle: "", previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ @@ -46,7 +45,7 @@ export default function CollectionList (data: { ], } satisfies GameMetaExtra))} onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect} - onGameFocus={(id, node, details) => + onFocus={(id, node, details) => { data.setBackground( `https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`, diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 0b9e0e1..8108149 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -10,7 +10,7 @@ import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { gameQuery } from '../scripts/queries/romm'; +import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; import { RoundButton } from './RoundButton'; @@ -41,7 +41,6 @@ export interface CollectionsDetailParams export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); const finalFilter = { ...data.localFilter, ...data.filters }; const focusKey = `game-list-${data.id}`; @@ -50,6 +49,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); + const { data: filterValues } = useQuery(gameFiltersQuery({ source: data.filters?.source })); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => @@ -79,7 +80,6 @@ export function CollectionsDetail (data: CollectionsDetailParams) = { store: , local: , romm: +}; + +export const pluginCategoryIcons: Record = { + saves: , + sources: , + launchers: , + emulators: +}; + +export const pluginCategoryPriorities: Record = { + saves: 100, + sources: 90, + launchers: 80, + emulators: 60 }; \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index 39bd6ab..3d8ea25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -13,16 +13,24 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); - const subtitle =
    - {!!data.game.path_platform_cover && } -

    {data.game.platform_display_name}

    -
    ; + let subtitle: any = undefined; + if (data.game.path_platform_cover) + { + const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + subtitle =
    + {!!data.game.path_platform_cover && } +

    {data.game.platform_display_name}

    +
    ; + } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`); - previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "640"); + const previewUrls = data.game.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + url.searchParams.set('width', "640"); + return url; + }); const badges: JSX.Element[] = []; @@ -53,7 +61,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG badges={badges} onFocus={data.onFocus} onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)} - preview={previewUrl.href} + preview={previewUrls} title={data.game.name ?? ""} subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index b94a45d..73caa98 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; @@ -19,7 +19,6 @@ export interface GameListParams extends FocusParams className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; - setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) @@ -37,7 +36,7 @@ export function GameList (data: GameListParams) try { const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined; - const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_covers[0]}`); const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl); previewUrl.searchParams.delete('ts'); data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href); @@ -48,11 +47,6 @@ export function GameList (data: GameListParams) } }; - useEffect(() => - { - data.setFilterValues?.(games.data.filters); - }, [games.data.filters]); - function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -79,23 +73,31 @@ export function GameList (data: GameListParams) badges.push(); } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - previewUrl.searchParams.delete('ts'); + const previewUrls = g.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + return url; + }); - const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); + let platformUrl: URL | undefined = undefined; + if (g.path_platform_cover) + { + platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + } return { id: `${g.id.source}@${g.id.id}`, - focusKey: g.slug ?? `game-${g.id}`, + focusKey: `${data.id}-${g.id.source}@${g.id.id}`, title: g.name ?? "", subtitle: (
    - {!!g.path_platform_cover && } +

    {g.platform_display_name}

    ), - previewUrl: previewUrl.href, + previewUrls: previewUrls, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), onFocus: () => handleFocus(g.id, g.source, g.source_id) diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 3a2e8dd..803e9c7 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -13,6 +13,7 @@ import BatteryWarning, Bell, Bluetooth, + CircleFadingArrowUp, Clock, Settings, Wifi, @@ -31,6 +32,7 @@ import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { SystemInfoContext } from "../scripts/contexts"; import { useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; +import { hasUpdateQuery } from "../scripts/queries/system"; function HeaderAvatar (data: { id: string; @@ -83,6 +85,14 @@ export interface HeaderAccount action?: () => void; } +function UpdateStatus () +{ + const hasUnread = false; + return
    + +
    ; +} + function NotificationStatus () { const hasUnread = false; @@ -249,13 +259,15 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + const { data: hasUpdate } = useQuery(hasUpdateQuery); return
    -
    +
    + {!!hasUpdate && hasUpdate >= 1 && }
    {!!data.buttons &&
    } diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx new file mode 100644 index 0000000..7954da3 --- /dev/null +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -0,0 +1,19 @@ +export default function ImageWithFallbacks (data: { + src: URL[]; + draggable?: boolean; + className?: string; +}) +{ + const handleError = (e: React.SyntheticEvent) => + { + const img = e.currentTarget; + const nextIndex = Number(img.dataset.index) + 1; + + if (nextIndex < data.src.length) + { + img.dataset.index = String(nextIndex); + img.src = data.src[nextIndex].href; + } + }; + return ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index e25dc26..d811d55 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n ref={ref} title="Games" id={`card-list-placeholder`} - save-child-focus="session" className={twMerge("items-center justify-center-safe h-full", data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" : 'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!', diff --git a/src/mainview/components/LoadingScreen.tsx b/src/mainview/components/LoadingScreen.tsx new file mode 100644 index 0000000..f92cc98 --- /dev/null +++ b/src/mainview/components/LoadingScreen.tsx @@ -0,0 +1,9 @@ +export default function LoadingScreen (data: { children?: any; }) +{ + return
    +
    +
    +
    + {data.children} +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 039e20a..2efcfbd 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -3,10 +3,36 @@ import { useNavigate } from "@tanstack/react-router"; import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; -import { JSX, useMemo } from "react"; -import { HardDrive } from "lucide-react"; +import { JSX, useMemo, useState } from "react"; +import { Gamepad2, HardDrive } from "lucide-react"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; +import placeholder from '../assets/256x256.png?url'; + +function Preview (data: { index: number, pathCover: string | null; }) +{ + const coverUrl = new URL(`${RPC_URL(__HOST__)}${data.pathCover}`); + coverUrl.searchParams.set('width', "320"); + const isMobile = mobileCheck(); + return
    + e.currentTarget.src = placeholder} + src={coverUrl.href} + > + +
    ; +} export function PlatformsList (data: { id: string, @@ -17,7 +43,7 @@ export function PlatformsList (data: { saveChildFocus?: "session" | "local"; } & FocusParams) { - const isMobile = mobileCheck(); + const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( { @@ -44,37 +70,19 @@ export function PlatformsList (data: { badges.push({g.game_count}); if (g.hasLocal) badges.push(); - const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - coverUrl.searchParams.set('width', "320"); + const entry: GameMetaExtra = { id: g.slug, focusKey: g.slug, title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", + subtitle: g.family_name ?? undefined, + previewUrls: "", badges, onFocus: () => data.setBackground( g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`, ), onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id), - preview: - () =>
    - -
    - , + preview: () => }; return entry; }), [platforms]); diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4ad4878..4caf3a6 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react"; +import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { systemApi } from "../scripts/clientApi"; import { FOCUS_KEYS } from "../scripts/types"; @@ -54,12 +54,24 @@ export default function SelectMenu (data: { rootFocusKey: string; }) action (ctx) { setOpen(false); - navigate({ to: "/settings/accounts" }); + navigate({ to: "/settings/interface" }); }, - selected: !!matchRoute({ to: '/settings/accounts' }), + selected: !!matchRoute({ to: '/settings' }) && !matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), type: "accent", id: "settings-m" }, + { + content: "Plugins", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/plugins" }); + }, + selected: !!matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), + type: "accent", + id: "plugins-m" + }, { content: "Reload", icon: , diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index e950a67..7a6db5e 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -12,7 +12,7 @@ export default function ActionButton (data: { square?: boolean, onFocus?: () => void; tooltip?: string, - tooltip_type?: 'accent' | 'error'; + tooltipType?: 'accent' | 'error'; disabled?: boolean; } & InteractParams) { @@ -30,7 +30,7 @@ export default function ActionButton (data: { ref={ref} onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })} data-tooltip={data.tooltip} - data-tooltip-type={data.tooltip_type} + data-tooltip-type={data.tooltipType} className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 8730779..446de01 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -1,10 +1,10 @@ -import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm"; +import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, updateSourceMutation, validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; -import { Hammer, Settings, Trash, Trophy } from "lucide-react"; +import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react"; import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; @@ -34,7 +34,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const fixMutation = useMutation({ - ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context) + ...fixSourceMutation, + onSuccess (data, variables, onMutateResult, context) { if (onMutateResult) toast.success("Updated Source"); context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back()); @@ -44,6 +45,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, toast.error(getErrorMessage(error) ?? "Error While Trying To Fix"); } }); + const updateMutation = useMutation({ + ...updateSourceMutation, + onSuccess (data, variables, onMutateResult, context) + { + if (onMutateResult) toast.success("Updated Source"); + context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Trying To Update"); + } + }); const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); const router = useRouter(); @@ -62,7 +75,7 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, useBlocker({ shouldBlockFn: () => { - return deleteMutation.isPending || fixMutation.isPending; + return deleteMutation.isPending || fixMutation.isPending || updateMutation.isPending; } }); @@ -85,15 +98,34 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { contextOptions.push({ id: "fix_source", - action (ctx) + async action (ctx) { - if (data.game) - fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }); + if (!data.game) return; + await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); }, icon: fixMutation.isPending ? : , content: "Try Fix Source", type: "warning" }); + } else if (data.game?.id.source === 'local') + { + contextOptions.push({ + id: 'update_source', + async action (ctx) + { + if (data.game) + { + await updateMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); + } + }, + icon: updateMutation.isPending ? : , + content: "Update Metadata", + type: "primary" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c4166a5..ce7add0 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -40,7 +40,7 @@ export default function Details (data: { const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; if (platformCoverImg) platformCoverImg.searchParams.set("width", "64"); - const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; + const gameCoverImg = data.game?.path_covers ? `${RPC_URL(__HOST__)}${data.game?.path_covers[0]}` : undefined; let fileSizeIcon: JSX.Element | undefined; if (!data.game) diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index d4067ee..681afd2 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -69,7 +69,6 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { const errorMessage = getErrorMessage(e.data.error); if (!errorMessage) return; - toast.error(errorMessage); setError(errorMessage); } }); @@ -137,7 +136,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { @@ -169,7 +168,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { case 'present': case 'install': - installMut.mutate(); + installMut.mutate({}); break; } }} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 31c1d27..08a6261 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -19,6 +19,7 @@ export function OptionInput (data: { step?: number; defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; + compact?: boolean; onBlur?: FocusEventHandler; onChange?: (value: string | number | boolean) => void; }) @@ -121,7 +122,7 @@ export function OptionInput (data: { }; return ( -