From 816d50ae4d61723e67a0980ca310561ead661a68 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sat, 28 Mar 2026 17:32:51 +0200 Subject: [PATCH] fix: Fixed romm login, now uses token feat: Moved romm to internal plugin fix: Made focusing and navigation more reliable fix: Loading errors on first time launch --- bun.lock | 7 +- package.json | 3 +- scripts/dev.ts | 2 +- src/bun/api/app.ts | 3 - src/bun/api/auth.ts | 173 ++++---- src/bun/api/clients.ts | 3 +- src/bun/api/games/collections.ts | 15 + src/bun/api/games/games.ts | 128 +++--- src/bun/api/games/platforms.ts | 99 ++--- src/bun/api/games/services/statusService.ts | 93 ++-- src/bun/api/games/services/utils.ts | 131 ++---- src/bun/api/hooks/app.ts | 6 +- src/bun/api/hooks/auth.ts | 8 + src/bun/api/hooks/emulators.ts | 25 +- src/bun/api/hooks/games.ts | 64 +++ src/bun/api/jobs/bios-download-job.ts | 45 +- src/bun/api/jobs/install-job.ts | 189 +++----- src/bun/api/jobs/launch-game-job.ts | 24 +- src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/twitch-login-job.ts | 3 + .../pcsx2.ts | 5 - .../package.json | 12 + .../com.simeonradivoev.gameflow.romm/romm.ts | 408 ++++++++++++++++++ src/bun/api/plugins/plugin-manager.ts | 3 +- src/bun/api/plugins/register-plugins.ts | 13 +- src/bun/api/settings/services.ts | 10 +- .../api/store/services/emulatorsService.ts | 12 +- src/bun/api/store/services/gamesService.ts | 4 +- src/bun/api/store/store.ts | 13 +- src/bun/api/system.ts | 31 +- src/bun/server.ts | 14 +- src/bun/utils.ts | 36 +- src/bun/utils/downloader.ts | 16 +- .../components/AnimatedBackground.tsx | 4 +- src/mainview/components/AutoFocus.tsx | 8 +- src/mainview/components/CardElement.tsx | 7 +- src/mainview/components/CardList.tsx | 79 ++-- src/mainview/components/CollectionList.tsx | 24 +- src/mainview/components/CollectionsDetail.tsx | 65 +-- src/mainview/components/ContextDialog.tsx | 23 +- src/mainview/components/FilePicker.tsx | 10 +- src/mainview/components/GameList.tsx | 6 +- src/mainview/components/Header.tsx | 93 ++-- src/mainview/components/LoadMoreButton.tsx | 5 + src/mainview/components/LoadingCardList.tsx | 45 +- src/mainview/components/PlatformsList.tsx | 2 +- src/mainview/components/StatList.tsx | 4 +- src/mainview/components/game/ActionButton.tsx | 2 +- .../components/game/ActionButtons.tsx | 14 +- src/mainview/components/game/Details.tsx | 7 +- src/mainview/components/game/MainActions.tsx | 29 +- .../components/options/OptionDropdown.tsx | 1 + .../components/store/StoreEmulatorCard.tsx | 22 +- src/mainview/gen/routeTree.gen.ts | 42 +- src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.css | 16 +- src/mainview/index.html | 2 + src/mainview/index.tsx | 6 +- src/mainview/preload.tsx | 21 + src/mainview/routes/__root.tsx | 23 +- src/mainview/routes/collection.$id.tsx | 27 -- .../routes/collection.$source.$id.tsx | 25 ++ src/mainview/routes/game/$source.$id.tsx | 94 +--- src/mainview/routes/games.tsx | 7 +- src/mainview/routes/index.tsx | 34 +- src/mainview/routes/platform.$source.$id.tsx | 25 +- src/mainview/routes/settings/accounts.tsx | 26 +- src/mainview/routes/settings/emulators.tsx | 114 +++-- src/mainview/routes/settings/plugins.tsx | 4 +- .../routes/store/details.emulator.$id.tsx | 23 +- src/mainview/routes/store/tab/emulators.tsx | 6 +- src/mainview/routes/store/tab/games.tsx | 10 +- src/mainview/routes/store/tab/index.tsx | 4 +- src/mainview/routes/store/tab/route.tsx | 18 +- src/mainview/scripts/contexts.ts | 4 + src/mainview/scripts/queries/romm.ts | 50 ++- src/mainview/scripts/queries/store.ts | 6 +- src/mainview/scripts/spatialNavigation.ts | 18 + src/mainview/scripts/utils.ts | 13 - src/shared/constants.ts | 15 + src/shared/types..d.ts | 71 ++- 81 files changed, 1659 insertions(+), 1097 deletions(-) create mode 100644 src/bun/api/games/collections.ts create mode 100644 src/bun/api/hooks/auth.ts create mode 100644 src/bun/api/hooks/games.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts create mode 100644 src/mainview/preload.tsx delete mode 100644 src/mainview/routes/collection.$id.tsx create mode 100644 src/mainview/routes/collection.$source.$id.tsx diff --git a/bun.lock b/bun.lock index be22c73..f3813b7 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@auth/core": "^0.34.3", "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", - "@elysiajs/static": "^1.4.7", "@jimp/wasm-webp": "^1.6.0", "cheerio": "^1.2.0", "conf": "^15.0.2", @@ -24,7 +23,7 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", - "systeminformation": "^5.31.1", + "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", @@ -149,8 +148,6 @@ "@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="], - "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="], - "@emulatorjs/core-81": ["@emulatorjs/core-81@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-oPQEqjpR3z7Yedte4u3sOXDZ4NXAykNcbENjYcB+x3QshF8I+3MQCo8kINOT2lsqqgx91WR4kmEaYQqU39YsDA=="], "@emulatorjs/core-a5200": ["@emulatorjs/core-a5200@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-/9yS0/MKHp/wO9iuxWfWTGUwiVNKykEOb7fEN5UM9BfIVQ1SAqep4Ji+TigmYW4weH/mASvYzON9ett3dmD6oQ=="], @@ -1535,7 +1532,7 @@ "sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="], - "systeminformation": ["systeminformation@5.31.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA=="], + "systeminformation": ["systeminformation@5.31.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], diff --git a/package.json b/package.json index 23a31de..bac8d98 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@auth/core": "^0.34.3", "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", - "@elysiajs/static": "^1.4.7", "@jimp/wasm-webp": "^1.6.0", "cheerio": "^1.2.0", "conf": "^15.0.2", @@ -59,7 +58,7 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", - "systeminformation": "^5.31.1", + "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", diff --git a/scripts/dev.ts b/scripts/dev.ts index 5728f96..5357463 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -12,7 +12,7 @@ let retries = 0; function spawnServer () { - return Bun.spawn(["bun", '--watch', '--install=fallback', '--smol', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { + return Bun.spawn(["bun", '--watch', '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { env: { ...process.env, HEADLESS: "true", diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 39b84ed..37b09d6 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -13,7 +13,6 @@ import { client } from "@clients/romm/client.gen"; import * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; import * as emulatorSchema from "@schema/emulators"; -import { login, logout } from "./auth"; import os from 'node:os'; import EventEmitter from "node:events"; import { appPath } from "../utils"; @@ -63,7 +62,6 @@ const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.pla export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); export const taskQueue = new TaskQueue(); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); -await login(); export const plugins = new PluginManager(); registerPlugins(plugins); export const events = new EventEmitter(); @@ -74,7 +72,6 @@ export async function cleanup () { await taskQueue.close(); sqlite.close(); - await logout(); emulatorsSqlite.close(); } diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index c05749d..b171ed0 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,8 +1,7 @@ import Elysia, { status } from "elysia"; -import { config, events, jar, taskQueue } from "./app"; +import { config, events, jar, plugins, taskQueue } from "./app"; import z from "zod"; -import { client } from "@clients/romm/client.gen"; -import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm"; +import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm"; import secrets from '../api/secrets'; import { LoginJob } from "./jobs/login-job"; import TwitchLoginJob from "./jobs/twitch-login-job"; @@ -43,6 +42,8 @@ export default new Elysia() await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' }); await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' }); + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); + return status(res.status, res.statusText); }) .get('/login/twitch', async () => @@ -93,6 +94,8 @@ export default new Elysia() await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); + events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); @@ -104,7 +107,7 @@ export default new Elysia() return status(400, res.statusText); }) - .post('/login/romm', async () => + .post('/login/romm/qr', async () => { if (taskQueue.hasActiveOfType(LoginJob)) { @@ -113,117 +116,87 @@ export default new Elysia() return taskQueue.enqueue(LoginJob.id, new LoginJob()); }) - .post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) - .get('/login', async () => + .get('/user/romm', async () => { - const credentials = await secrets.get({ service: 'gameflow', name: 'romm' }); - return { hasPassword: !!credentials }; - }, { response: z.object({ hasPassword: z.boolean() }) }) - .post('/logout', async () => + const data = await getCurrentUserApiUsersMeGet(); + if (data.error) return status("Internal Server Error", data.response.statusText); + return data.data as UserSchema; + }) + .post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) + .get('/login/romm', async () => { - await secrets.delete({ service: 'gameflow', name: 'romm' }); - await logout(); - const rommAddress = config.get('rommAddress'); - if (rommAddress) + const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); + if (!access_token) { - const cookies = await jar.getCookies(rommAddress); - cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key)); + return { hasLogin: false }; } + + const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); + if (expires_in) + { + const date = new Date(expires_in); + if (date > new Date()) + { + return { hasLogin: true }; + } + } + + const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); + if (!refresh_token) + { + return { hasLogin: false }; + } + + const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); + + if (refreshResponse.response.ok && refreshResponse.data) + { + await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); + if (refreshResponse.data.refresh_token) + await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); + await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); + + events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); + return { hasLogin: true }; + } + + return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; + }, + { response: z.object({ hasLogin: z.boolean() }) }) + .post('/logout/romm', async () => + { + await secrets.delete({ service: 'gameflow', name: 'romm_access_token' }); + await secrets.delete({ service: 'gameflow', name: 'romm_refresh_token' }); + await secrets.delete({ service: 'gameflow', name: 'romm_expires_in' }); return status(200); }, { response: z.any() }); -async function updateClient () -{ - client.setConfig({ - baseUrl: config.get('rommAddress'), headers: { - cookie: await jar.getCookieString(config.get('rommAddress') ?? '') - } - }); -} + export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; }) { - if (config.has('rommAddress') && config.has('rommUser')) + const response = await tokenApiTokenPost({ + body: { + password, + username, + scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' + }, baseUrl: host + }); + + if (response.response.ok && response.data) { - await logout(); - const oldRommAddress = config.get('rommAddress'); - if (oldRommAddress) + await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: response.data.access_token }); + await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + response.data.expires * 1000).toString() }); + if (response.data.refresh_token) { - const cookies = await jar.getCookies(oldRommAddress); - await Promise.all(cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key))); + await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: response.data.refresh_token }); } - } - const response = await login({ rommAddress: host, rommUser: username, rommPassword: password }); - if (response?.code === 200) - { config.set('rommAddress', host); - config.set('rommUser', username); - - await secrets.set({ service: 'gameflow', name: 'romm', value: password }); + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); } return response; -} - -export async function logout () -{ - if (!config.has('rommAddress')) - { - return; - } - const rommAddress = config.get('rommAddress'); - if (rommAddress) - { - console.log("Logging Out of ROMM"); - try - { - await logoutApiLogoutPost({ - baseUrl: rommAddress, headers: { - 'cookie': await jar.getCookieString(rommAddress) - } - }); - await jar.store.removeCookie(new URL(rommAddress).host, null, "romm_session"); - } catch (error) - { - console.error("Failed to logout of ROMM ", error); - } - } -} - -export async function login (data?: { rommAddress?: string, rommUser?: string, rommPassword?: string; }) -{ - const address = data?.rommAddress ?? config.get('rommAddress'); - const user = data?.rommUser ?? config.get('rommUser'); - const password = data?.rommPassword ?? await secrets.get({ service: 'gameflow', name: "romm" }); - - if (!address || !user) - { - console.warn("Romm not setup"); - return status(404); - } - const rommAddress = config.get('rommAddress'); - const rommUser = config.get('rommUser'); - if (rommAddress && rommUser) - { - console.log("Logging In to ROMM"); - if (password === null) - { - return status(404, "No Found Password"); - } - - const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` }); - if (loginResponse.response.status === 200) - { - loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress)); - await updateClient(); - return status(200, loginResponse.response.statusText); - } else - { - console.error("Could not Login to Romm: ", loginResponse.response.statusText); - return status(loginResponse.response.status, loginResponse.response.statusText); - } - - } -} - +} \ No newline at end of file diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index 6a7c17f..7d117d3 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -4,9 +4,10 @@ import { config, jar } from "./app"; import games from "./games/games"; import platforms from "./games/platforms"; import auth from "./auth"; +import collections from "./games/collections"; export default new Elysia({ prefix: "/api/romm" }) - .use([games, platforms, auth]) + .use([games, platforms, collections, auth]) .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/games/collections.ts b/src/bun/api/games/collections.ts new file mode 100644 index 0000000..6728845 --- /dev/null +++ b/src/bun/api/games/collections.ts @@ -0,0 +1,15 @@ +import Elysia, { status } from "elysia"; +import { plugins } from "../app"; + +export default new Elysia() + .get('/collections', async () => + { + const collections: FrontEndCollection[] = []; + await plugins.hooks.games.fetchCollections.promise({ collections }); + return collections; + }).get('/collection/:source/:id', async ({ params: { source, id } }) => + { + const collection = await plugins.hooks.games.fetchCollection.promise({ source, id }); + if (!collection) return status("Not Found"); + return collection; + }); \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index c065657..563bab0 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,14 +1,13 @@ import Elysia, { status } from "elysia"; -import { config, db, emulatorsDb, taskQueue } from "../app"; +import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; import { and, eq, getTableColumns, inArray, 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 { getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { convertLocalToFrontend, convertRomToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +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"; @@ -19,7 +18,6 @@ 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 { CACHE_KEYS, getOrCached } from "../cache"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; @@ -34,7 +32,7 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, try { - if ((blur && !noBlur) || width || height) + if ((blur && !noBlur)) { const jimp = await Jimp.read(img); @@ -50,7 +48,7 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, { jimp.resize({ w: width, h: height }); } - return jimp.getBuffer('image/webp'); + return jimp.getBuffer('image/png'); } } catch (e) { @@ -174,6 +172,17 @@ export default new Elysia() if (query.platform_slug) { 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) + { + where.push(eq(schema.platforms.slug, platform?.slug)); + } } if (query.source) @@ -190,37 +199,54 @@ export default new Elysia() .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) - .offset(query.offset ?? 0) - .limit(query.limit ?? 50) .where(and(...where)); localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); if (!query.collection_id) { - games.push(...localGames.map(g => + games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => { return convertLocalToFrontend(g); })); - } - if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) + const remoteGames: FrontEndGameType[] = []; + 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 rommGames = await getRomsApiRomsGet({ - query: { - platform_ids: query.platform_id ? [query.platform_id] : undefined, - collection_id: query.collection_id, - limit: query.limit, - offset: query.offset - }, throwOnError: true - }); - games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g => + const remoteGames: FrontEndGameType[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => { - return convertRomToFrontend(g); + if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) + { + return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); + } else + { + return g; + } })); } } + if (query.orderBy) + { + switch (query.orderBy) + { + case 'added': + games.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime()); + break; + case 'activity': + games.sort((a, b) => Math.max(b.updated_at.getTime(), b.last_played?.getTime() ?? 0) - Math.max(a.updated_at.getTime(), a.last_played?.getTime() ?? 0)); + break; + case 'name': + games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + break; + } + + } + return { games }; }, { query: GameListFilterSchema, @@ -274,7 +300,7 @@ export default new Elysia() { return { name: 'EMULATORJS', - validSource: { binPath: SERVER_URL(host), type: 'embedded', exists: true }, + validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], gameCount: 0 @@ -286,7 +312,8 @@ export default new Elysia() name: name, logo: "", systems: [], - gameCount: 0 + gameCount: 0, + validSources: [] } satisfies FrontEndGameTypeDetailedEmulator; } @@ -323,7 +350,7 @@ export default new Elysia() { if (source === 'romm' || source === 'store') { - taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, id, { dryRun: true })); + taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); return status(200); } @@ -338,7 +365,7 @@ export default new Elysia() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => { - const job = taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob); + const job = taskQueue.findJob(InstallJob.query({ source, id }), InstallJob); if (job) { job.abort('cancel'); @@ -408,7 +435,7 @@ export default new Elysia() if (!emulator) return status("Not Found"); const systems = await buildStoreFrontendEmulatorSystems(emulator); const systemsIdSet = new Set(systems.map(s => s.id)); - const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); + const games: FrontEndGameType[] = []; @@ -431,31 +458,9 @@ export default new Elysia() return convertLocalToFrontend(g); }).slice(0, 3)); - const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); - - if (rommPlatforms) - { - const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id); - if (platformIds.length > 0) - { - const rommGames = await getRomsApiRomsGet({ - query: { - platform_ids: platformIds - } - }); - - let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size); - - for (const slug of systemsRommSlugSet) - { - const systemRommGames = rommGames.data?.items.filter(g => !localGamesSet?.has(`romm@${g.id}`) && slug === g.platform_slug).map(g => - { - return convertRomToFrontend(g); - }).slice(0, gamesPerSystem) ?? []; - games.push(...systemRommGames); - } - } - } + const remoteGames: FrontEndGameType[] = []; + 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 @@ -502,20 +507,6 @@ export default new Elysia() games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); - const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); - if (rommPlatforms) - { - const rommPlatform = rommPlatforms.find(p => p.slug === sourceData.platform_slug); - if (rommPlatform) - { - const rommGames = await getRomsApiRomsGet({ query: { genres: sourceData.genres, genres_logic: 'any' } }); - if (rommGames.data) - { - games.push(...rommGames.data.items.filter(g => !localGamesSourceSet.has(`romm@${g.id}`)).map(g => ({ ...convertRomToFrontend(g), metadata: g.metadatum }))); - } - } - } - const shuffledGames = await getShuffledStoreGames(); const storeGames = await Promise.all(shuffledGames .filter(g => @@ -546,6 +537,13 @@ export default new Elysia() games.push(...storeGames.slice(0, 3)); } + const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; + plugins.hooks.games.fetchRecommendedGamesForGame.promise({ + game: sourceData, games: remoteGames + }); + + games.push(...remoteGames.filter(g => !localGamesSourceSet.has(`${g.id.source}@${g.id.id}`))); + const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60)); const rankedGames = games.filter(g => diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index 64371c0..a33e155 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,18 +1,12 @@ import Elysia, { status } from "elysia"; -import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import z from "zod"; import { and, count, eq, getTableColumns, not } from "drizzle-orm"; -import { db } from "../app"; +import { db, plugins } from "../app"; import * as schema from "@schema/app"; -import { CACHE_KEYS, getOrCached } from "../cache"; export default new Elysia() .get('/platforms', async () => { - const platforms: FrontEndPlatformType[] = []; - let rommPlatformsSet: Set | undefined; - const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); - const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) }) .from(schema.platforms) .leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id)) @@ -20,49 +14,31 @@ export default new Elysia() const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug)); - if (rommPlatforms) + const remotePlatforms: FrontEndPlatformType[] = []; + + await plugins.hooks.games.fetchPlatforms.promise({ platforms: remotePlatforms }); + + await Promise.all(remotePlatforms.map(async p => { - const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => + p.hasLocal = localPlatformSet.has(p.slug); + + if (p.paths_screenshots.length <= 0) { - const screenshots: string[] = []; - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data); - if (rommGames) - { - const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`); - if (rommScreenshots) - screenshots.push(...rommScreenshots); - } + const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1); - if (screenshots.length <= 0) - { - const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1); + if (localScreenshots) + p.paths_screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`)); + } - if (localScreenshots) - screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`)); - } + const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id); + p.game_count += localGames.length; + })); - const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id); + const platformSlugSet = new Set(remotePlatforms.map(p => p.slug)); - const platform: FrontEndPlatformType = { - slug: p.slug, - name: p.display_name, - family_name: p.family_name, - path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`, - game_count: p.rom_count + localGames.length, - updated_at: new Date(p.updated_at), - id: { source: 'romm', id: String(p.id) }, - hasLocal: localPlatformSet.has(p.slug), - paths_screenshots: screenshots - }; - - return platform; - })); - - rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug)); - platforms.push(...frontEndPlatforms); - } - - platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p => + const platforms: FrontEndPlatformType[] = []; + platforms.push(...remotePlatforms); + platforms.push(...await Promise.all(localPlatforms.filter(p => !platformSlugSet?.has(p.slug)).map(async p => { const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) }); let screenshots: { id: number; }[] = []; @@ -90,31 +66,9 @@ export default new Elysia() return { platforms }; }).get('/platforms/:source/:id', async ({ params: { source, id } }) => { - if (source === 'romm') + if (source === 'local') { - const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } }); - if (rommPlatform) - { - const platform: FrontEndPlatformType = { - slug: rommPlatform.slug, - name: rommPlatform.display_name, - family_name: rommPlatform.family_name, - path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`, - game_count: rommPlatform.rom_count, - updated_at: new Date(rommPlatform.updated_at), - id: { source: 'romm', id: String(rommPlatform.id) }, - paths_screenshots: [], - hasLocal: false - }; - - return platform; - } - - return status("Not Found", response); - } - else if (source === 'local') - { - const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) }); + const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); if (localPlatform) { const platform: FrontEndPlatformType = { @@ -133,10 +87,13 @@ export default new Elysia() } return status("Not Found"); + } else + { + const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); + if (!remotePlatform) return status("Not Found"); + return remotePlatform; } - - return status("Not Implemented"); - }, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).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'; diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 63b621f..b1ac7b9 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,11 +1,10 @@ import { RPC_URL, } from "@shared/constants"; -import { config, customEmulators, db, taskQueue } from "../../app"; +import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; import { getValidLaunchCommands } from "./launchGameService"; -import * as schema from '@schema/app'; -import { eq } from "drizzle-orm"; -import { getErrorMessage } from "@/bun/utils"; -import { getLocalGameMatch } from "./utils"; -import { getRomApiRomsIdGet } from "@/clients/romm"; +import * as emulatorSchema from '@schema/emulators'; +import { and, eq } from "drizzle-orm"; +import { getErrorMessage, hashFile } from "@/bun/utils"; +import { checkFiles, getLocalGameMatch } from "./utils"; import fs from 'node:fs/promises'; import { getStoreGameFromId } from "../../store/services/gamesService"; import { cores } from "../../emulatorjs/emulatorjs"; @@ -26,17 +25,15 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { - const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug }) - .from(schema.games) - .where(getLocalGameMatch(id, source)) - .leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)); + const localGame = await db.query.games.findFirst({ + columns: { id: true, path_fs: true }, + where: getLocalGameMatch(id, source), + with: { + platform: { columns: { slug: true } } + } + }); - if (localGames.length > 0) - { - return localGames[0]; - } - - return undefined; + return localGame; } export async function getValidLaunchCommandsForGame (source: string, id: string) @@ -44,22 +41,24 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) const localGame = await getLocalGame(source, id); if (localGame) { - if (localGame.platform_slug) + const rommPlatform = localGame.platform.slug; + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); + + if (esPlatform) { if (localGame.path_fs) { - try { - const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs }); + const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs }); - if (cores[localGame.platform_slug]) + if (cores[esPlatform.system]) { const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; commands.push({ id: 'EMULATORJS', label: "Emulator JS", - command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, + command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'EMULATORJS', metadata: { @@ -90,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) } else { - return new CommandSearchError('error', 'Missing Platform'); + return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); } } @@ -107,6 +106,7 @@ export default function buildStatusResponse () z.object({ status: z.literal(['refresh', 'queued']) }), z.object({ status: z.literal('playing'), details: z.string() }), z.object({ status: z.literal('install'), details: z.string() }), + z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), ]), message (ws, data) @@ -158,20 +158,6 @@ export default function buildStatusResponse () }); } - } - else if (ws.data.params.source === 'romm') - { - // TODO: Add Caching - const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(ws.data.params.id) } }); - const stats = await fs.statfs(config.get('downloadPath')); - if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail) - { - ws.send({ status: 'error', error: "Not Enough Free Space" }); - } else - { - ws.send({ status: 'install', details: 'Install' }); - } - } else if (ws.data.params.source === 'store') { const storeGame = await getStoreGameFromId(ws.data.params.id); @@ -186,6 +172,41 @@ export default function buildStatusResponse () { ws.send({ status: 'install', details: 'Install' }); } + } else + { + const files = await plugins.hooks.games.fetchDownloads.promise({ + source: ws.data.params.source, + id: ws.data.params.id + }); + + let filesChecked: LocalDownloadFileEntry[] | undefined; + + if (files) + { + filesChecked = await checkFiles(files.files, !!files.extract_path); + } + + if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) + { + ws.send({ status: 'present', details: "Files Exist On Disk, Import" }); + } else + { + const size = filesChecked?.filter(f => f.exists !== true || f.matches !== true).reduce((p, f) => p += f.size ?? 0, 0); + const stats = await fs.statfs(config.get('downloadPath')); + if (size && size > stats.bsize * stats.bavail) + { + ws.send({ status: 'error', error: "Not Enough Free Space" }); + } else if (filesChecked?.some(f => f.exists === true && f.matches === false)) + { + ws.send({ status: 'install', details: 'Some Files Present, Install' }); + } + else + { + ws.send({ status: 'install', details: 'Install' }); + } + } + + } } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index ca49808..91dff60 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -1,14 +1,14 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; -import { config, db, emulatorsDb } from "../../app"; +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 { isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; +import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; export async function calculateSize (installPath: string | null) { @@ -27,29 +27,6 @@ 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)); } -export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType -{ - const steamDeck = isSteamDeckGameMode(); - const game: FrontEndGameType = { - id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${steamDeck ? rom.path_cover_small : rom.path_cover_large}`, - last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, - updated_at: new Date(rom.updated_at), - slug: rom.slug, - platform_id: rom.platform_id, - platform_display_name: rom.platform_display_name, - name: rom.name, - path_fs: null, - path_platform_cover: `/api/romm/image/romm/assets/platforms/${rom.platform_slug}.svg`, - source: null, - source_id: null, - paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`), - platform_slug: rom.platform_slug - }; - - return game; -} - export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { platform?: typeof schema.platforms.$inferSelect | null; screenshotIds?: number[]; @@ -160,49 +137,6 @@ export async function convertStoreToFrontendDetailed (system: string, id: string return detailed; } -export async function convertRomToFrontendDetailed (rom: DetailedRomSchema) -{ - const detailed: FrontEndGameTypeDetailed = { - ...convertRomToFrontend(rom), - summary: rom.summary, - 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 - }; - - const userData = await getCurrentUserApiUsersMeGet(); - const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id); - - if (rom.merged_ra_metadata?.achievements) - { - const earnedMap = new Map(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }])); - detailed.achievements = { - unlocked: gameAchievements?.num_awarded ?? 0, - entires: rom.merged_ra_metadata.achievements.map(a => - { - const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined; - const ach: FrontEndGameTypeDetailedAchievement = { - id: a.badge_id ?? String(a.ra_id) ?? 'unknown', - title: a.title ?? "Unknown", - badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined, - date: earned?.date, - date_hardcode: earned?.date_hardcode, - description: a.description ?? undefined, - display_order: a.display_order ?? 0, - type: a.type ?? undefined - }; - - return ach; - }).sort((a, b) => a.display_order - b.display_order), - total: rom.merged_ra_metadata.achievements.length - }; - } - return detailed; -} - export async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ @@ -254,29 +188,8 @@ export async function getSourceGameDetailed (source: string, id: string) else { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (source === 'romm') - { - const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); - if (rom.data) - { - const romGame = await convertRomToFrontendDetailed(rom.data); - if (localGame) - { - return { - ...romGame, - ...localGame, - }; - } - return romGame; - } - else if (localGame) - { - return localGame; - } - return undefined; - } - else if (source === 'store') + if (source === 'store') { const gameId = extractStoreGameSourceId(id); const storeGame = await getStoreGame(gameId.system, gameId.id); @@ -287,11 +200,45 @@ export async function getSourceGameDetailed (source: string, id: string) return { ...storeFrontendGame, ...localGame }; } return storeFrontendGame; - } else if (localGame) + } else { - return localGame; + const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); + if (remoteGame) + { + return remoteGame; + } else if (localGame) + { + return localGame; + } } return undefined; } +} + +export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean): Promise +{ + return Promise.all(files.map(async f => + { + // file is either zip or doesn't support sha checking + if (!f.sha1 || isArchive) return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; + const localPath = path.join(f.file_path, f.file_name); + if (await fs.exists(localPath)) + { + if (f.size && f.size !== (await fs.stat(localPath)).size) + { + return { ...f, exists: true, matches: false } satisfies LocalDownloadFileEntry; + } + + const existingHash = await hashFile(localPath, 'sha1'); + if (existingHash === f.sha1) + { + return { ...f, exists: true, matches: true } satisfies LocalDownloadFileEntry; + } else + { + return { ...f, exists: true, matches: false } satisfies LocalDownloadFileEntry; + } + } + return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; + })); } \ No newline at end of file diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts index 83b797d..bf592a0 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/bun/api/hooks/app.ts @@ -1,6 +1,10 @@ -import { GameHooks } from "./emulators"; +import { AuthHooks } from "./auth"; +import { EmulatorHooks } from "./emulators"; +import { GameHooks } from "./games"; export class GameflowHooks { games = new GameHooks(); + emulators = new EmulatorHooks(); + auth = new AuthHooks(); } \ No newline at end of file diff --git a/src/bun/api/hooks/auth.ts b/src/bun/api/hooks/auth.ts new file mode 100644 index 0000000..7234992 --- /dev/null +++ b/src/bun/api/hooks/auth.ts @@ -0,0 +1,8 @@ +import { AsyncSeriesHook } from "tapable"; + +export class AuthHooks +{ + loginComplete = new AsyncSeriesHook<[ctx: { + service: string; + }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index 54e783b..f48ea9f 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -1,21 +1,10 @@ -import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook } from 'tapable'; +import { AsyncSeriesBailHook } from "tapable"; -export class GameHooks +export class EmulatorHooks { - /** 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 - * @param ctx.game.source The source of the game - * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was - * @returns The argument list to be used when running the emulator. - * If no emulator bin in the command entry is found the actual command will be used as the bin. - */ - emulatorLaunch = new AsyncSeriesBailHook<[ctx: { - autoValidCommand: CommandEntry; - game: { - source: string; - sourceId: string; - id: number; - }; - }], string[] | undefined>(['ctx']); + fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { + emulator: string; + systems: EmulatorSystem[]; + biosFolder: string; + }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts new file mode 100644 index 0000000..ff3ec04 --- /dev/null +++ b/src/bun/api/hooks/games.ts @@ -0,0 +1,64 @@ +import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; +import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable'; + +export class GameHooks +{ + /** 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 + * @param ctx.game.source The source of the game + * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was + * @returns The argument list to be used when running the emulator. + * If no emulator bin in the command entry is found the actual command will be used as the bin. + */ + emulatorLaunch = new AsyncSeriesBailHook<[ctx: { + autoValidCommand: CommandEntry; + game: { + source: string; + id: number; + }; + }], string[] | undefined>(['ctx']); + /** + * Fetches and returns a list of games converted to frontend. + * @param ctx.localGameIds This is local game ids in the format '@' + */ + fetchGames = new AsyncSeriesHook<[ctx: { + query: GameListFilterType; + games: FrontEndGameType[]; + }]>(['ctx']); + fetchGame = new AsyncSeriesBailHook<[ctx: { + source: string; + localGame?: FrontEndGameTypeDetailed; + id: string; + }], FrontEndGameTypeDetailed | undefined>(['ctx']); + /** Get download file URLs + * @param ctx.checksum Check if file already exists using checksums + */ + fetchDownloads = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], DownloadInfo | undefined>(['ctx']); + fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { + game: FrontEndGameTypeDetailed, + games: (FrontEndGameType & { metadata?: any; })[]; + }]>(['ctx']); + fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: { + emulator: EmulatorPackageType; + systems: EmulatorSystem[]; + games: FrontEndGameType[]; + }]>(['ctx']); + fetchPlatform = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], FrontEndPlatformType | undefined>(['ctx']); + platformLookup = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], { slug: string; } | undefined>(['ctx']); + fetchPlatforms = new AsyncSeriesHook<[ctx: { + platforms: FrontEndPlatformType[]; + }]>(['ctx']); + 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']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index 145d701..0259c95 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -1,11 +1,8 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; -import { CACHE_KEYS, getOrCached } from "../cache"; -import { config } from "../app"; -import { getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet } from "@/clients/romm"; -import fs from 'node:fs/promises'; -import { hashFile, simulateProgress } from "@/bun/utils"; -import { Downloader, FileEntry } from "@/bun/utils/downloader"; +import { config, plugins } from "../app"; +import { simulateProgress } from "@/bun/utils"; +import { Downloader } from "@/bun/utils/downloader"; import path from 'node:path'; import { ensureDir } from "fs-extra"; import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; @@ -27,46 +24,27 @@ export class BiosDownloadJob implements IJob, never, "download">) { - const allRommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data); - const emulator = await getStoreEmulatorPackage(this.emulator); if (!emulator) throw new Error("Could Not Find Emulator"); - const systems = await buildStoreFrontendEmulatorSystems(emulator); - const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); await ensureDir(biosFolder); - const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r); + const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder }); - const firmwaresToDownload: FileEntry[] = []; - - for (const rommPlatform of rommPlatforms) - { - const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data); - if (firmwares) - { - for (const firmware of firmwares) - { - const firmwarePath = path.join(biosFolder, firmware.file_name); - const exists = await fs.exists(firmwarePath); - - if (exists && await hashFile(firmwarePath, 'sha1')) - { - return; - } - - firmwaresToDownload.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) }); - } - } - } + if (!files) throw new Error("Could not find source to download from"); if (this.dryRun) { await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal); } else { - const downloader = new Downloader('bios-download', firmwaresToDownload, biosFolder, { + const headers: Record = {}; + if (files.auth) + headers['Authorization'] = files.auth; + + const downloader = new Downloader('bios-download', files.files, biosFolder, { signal: context.abortSignal, + headers, onProgress (stats) { context.setProgress(stats.progress, "download"); @@ -75,7 +53,6 @@ export class BiosDownloadJob implements IJob static dataSchema = z.never(); public gameId: string; public source: string; - public sourceId: string; public config?: JobConfig; public group = InstallJob.id; - constructor(id: string, source: string, sourceId: string, config?: JobConfig) + constructor(id: string, source: string, config?: JobConfig) { this.gameId = id; this.config = config; - this.sourceId = sourceId; this.source = source; } @@ -49,102 +49,53 @@ export class InstallJob implements IJob fs.mkdir(config.get('downloadPath'), { recursive: true }); const downloadPath = config.get('downloadPath'); - - let files: { - url: URL, - file_path: string; - file_name: string; - size?: number; - }[] = []; - let screenshotUrls: string[]; - let coverUrl: string; - let rommPlatform: PlatformSchema | undefined; - let slug: string | null; - let path_fs: string | undefined; - let summary: string | null; - let name: string | null; - let last_played: Date | null; - let igdb_id: number | null; - let ra_id: number | null; - let source_id: string; - let system_slug: string; - let extract_path: string; - let metadata: any | undefined; + let info: DownloadInfo | undefined; switch (this.source) { - case 'romm': - - const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data; - rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data; - - const rommAddress = config.get('rommAddress'); - coverUrl = `${rommAddress}${rom.path_cover_large}`; - screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`); - last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null; - igdb_id = rom.igdb_id; - ra_id = rom.ra_id; - summary = rom.summary; - name = rom.name; - path_fs = path.join(rom.fs_path, rom.fs_name); - source_id = String(rom.id); - slug = rom.slug; - system_slug = rommPlatform.slug; - extract_path = ''; - metadata = rom.metadatum; - - const rommFiles = await Promise.all(rom.files.map(async f => - { - const localPath = path.join(config.get('downloadPath'), f.full_path); - if (f.md5_hash && await fs.exists(localPath)) - { - const existingHash = await hashFile(localPath, 'sha1'); - if (existingHash === f.md5_hash) - { - console.log("File Already Present: ", f.full_path); - return undefined; - } - - console.warn("File ", f.full_path, 'with hash', existingHash, 'has different hash than', f.sha1_hash); - } - - return { - url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), - file_name: f.file_name, - file_path: path.join(config.get('downloadPath'), f.file_path), - size: f.file_size_bytes - }; - })); - - files.push(...rommFiles.filter(f => f !== undefined)); - break; case 'store': const game = await getStoreGameFromId(this.gameId); const gameId = extractStoreGameSourceId(this.gameId); - coverUrl = game.pictures.titlescreens[0]; - screenshotUrls = game.pictures.screenshots; - files.push({ url: new URL(game.file), file_path: `roms/${game.system}`, file_name: path.basename(decodeURI(game.file)) }); - slug = this.gameId; - source_id = this.gameId; - name = game.title; - summary = game.description; - system_slug = gameId.system; - extract_path = path.join('roms', gameId.system); + 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, + extract_path: path.join('roms', gameId.system), + }; break; default: - throw new Error("Unsupported source"); + info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId }); + break; } + if (!info) throw new Error(`Could not find downloader for source ${this.source}`); + + const files = await checkFiles(info.files, !!info.extract_path); + if (this.config?.dryRun !== true) { - if (this.config?.dryDownload !== true) + if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) { + const headers: Record = {}; + if (info.auth) + headers['Authorization'] = info.auth; const downloader = new Downloader(`game-${this.source}-${this.gameId}`, - files, + files.filter(f => !f.exists || !f.matches), config.get('downloadPath'), { signal: cx.abortSignal, + headers, onProgress (stats) { cx.setProgress(stats.progress, 'download'); @@ -152,21 +103,21 @@ export class InstallJob implements IJob }); const downloadedFiles = await downloader.start(); - if (extract_path && downloadedFiles) + if (info.extract_path && downloadedFiles) { for (const path of downloadedFiles) { - await _7z.unpack(path, extract_path); + await _7z.unpack(path, info.extract_path); } } } - if (this.config?.dryDownload === true) + if (this.config?.dryDownload === true && info.extract_path) { - await mkdir(path.join(downloadPath, extract_path), { recursive: true }); + await ensureDir(path.join(downloadPath, info.extract_path)); } - const coverResponse = await fetch(coverUrl); + const coverResponse = await fetch(info.coverUrl); const cover = Buffer.from(await coverResponse.arrayBuffer()); if (cx.abortSignal.aborted) return; @@ -174,18 +125,18 @@ export class InstallJob implements IJob await db.transaction(async (tx) => { // Search for existing platform - const platformSearch = [eq(schema.platforms.slug, system_slug)]; - const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)]; + const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; + const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; - if (rommPlatform) + if (info.platform) { - if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id)); - if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug)); - if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id)); - if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id)); + if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); + if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); + if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); + if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm')); - esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.slug)); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug)); } const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ @@ -201,9 +152,9 @@ 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/${system_slug}.svg`); + const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`); - if (!esPlatform && !rommPlatform) + if (!esPlatform && !info.platform) { // go to unknown platform existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); @@ -223,14 +174,14 @@ export class InstallJob implements IJob { // Create new local platform const platform: typeof schema.platforms.$inferInsert = { - slug: rommPlatform?.slug ?? esPlatform?.system.name ?? '', - igdb_id: rommPlatform?.igdb_id, - igdb_slug: rommPlatform?.igdb_slug, - ra_id: rommPlatform?.ra_id, + slug: info.platform?.slug ?? esPlatform?.system.name ?? '', + igdb_id: info.platform?.igdb_id, + igdb_slug: info.platform?.igdb_slug, + ra_id: info.platform?.ra_id, cover: Buffer.from(await platformCover.arrayBuffer()), cover_type: platformCover.headers.get('content-type'), - name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '', - family_name: rommPlatform?.family_name, + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, es_slug: esPlatform?.system.name ?? undefined }; @@ -246,38 +197,38 @@ export class InstallJob implements IJob // create the rom const game: typeof schema.games.$inferInsert = { - source_id, + source_id: info.source_id, source: this.source, - slug, - path_fs, - last_played: last_played, + slug: info.slug, + path_fs: info.path_fs, + last_played: info.last_played, platform_id: platformId, - igdb_id: igdb_id, - ra_id: ra_id, - summary: summary, - name, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + summary: info.summary, + name: info.name, cover, cover_type: coverResponse.headers.get('content-type'), - metadata + metadata: info.metadata }; const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - if (screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) + if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_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', '=', igdb_id)).execute(); + const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute(); - screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); + info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); } } // pre-fetch screenshots - const screenshots = await Promise.all(screenshotUrls.map(s => fetch(s))); + const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); if (screenshots.length > 0) { @@ -300,6 +251,6 @@ export class InstallJob implements IJob } - events.emit('notification', { message: `${name}: Installed`, type: 'success', duration: 8000 }); + events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } } \ No newline at end of file diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 8e97175..75b6ff0 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -3,9 +3,8 @@ import { IJob, JobContext } from "../task-queue"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { spawn } from 'node:child_process'; -import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; export class LaunchGameJob implements IJob, "playing"> { @@ -38,7 +37,7 @@ export class LaunchGameJob implements IJob { - updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } }); - events.emit('notification', { message: "Updated Last Played", type: 'success' }); - } + 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 (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); + }); + }; - if (this.gameSource === 'romm') + if (this.gameSource !== 'local') { - updateRommProps(Number(this.gameSourceId)); + updatePlayed(this.gameSource, this.gameSourceId); } - else if (localGame?.source === 'romm' && localGame.source_id) + else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id) { - updateRommProps(Number(localGame.source_id)); + updatePlayed(localGame.source, localGame.source_id); } }); diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index b10e087..f0726bd 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -37,7 +37,7 @@ export class LoginJob implements IJob, "base .post(`/login`, async ({ body }) => { const response = await tryLoginAndSave(body as any); - if (response?.code === 200) + if (response.response.ok) { context.abort("success"); return status("Accepted"); diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts index 59b5fdb..1023e83 100644 --- a/src/bun/api/jobs/twitch-login-job.ts +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -3,6 +3,7 @@ import secrets from "../secrets"; import open from "open"; import z from "zod"; import { delay } from "@/shared/utils"; +import { plugins } from "../app"; interface TwitchDevice @@ -94,6 +95,8 @@ export default class TwitchLoginJob implements IJob (await secrets.get({ service: "gameflow", name: 'romm_access_token' })) ?? undefined); + } + + async getAllRommPlatforms () + { + return getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data); + } + + convertRomToFrontend (rom: SimpleRomSchema) + { + 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, + updated_at: new Date(rom.created_at), + slug: rom.slug, + platform_id: rom.platform_id, + platform_display_name: rom.platform_display_name, + name: rom.name, + path_fs: null, + path_platform_cover: `/api/romm/image/romm/assets/platforms/${rom.platform_slug}.svg`, + source: null, + source_id: null, + paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`), + platform_slug: rom.platform_slug + }; + + return game; + } + + async convertRomToFrontendDetailed (rom: DetailedRomSchema) + { + const detailed: FrontEndGameTypeDetailed = { + ...this.convertRomToFrontend(rom), + summary: rom.summary, + 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 + }; + + const userData = await getCurrentUserApiUsersMeGet(); + const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id); + + if (rom.merged_ra_metadata?.achievements) + { + const earnedMap = new Map(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }])); + detailed.achievements = { + unlocked: gameAchievements?.num_awarded ?? 0, + entires: rom.merged_ra_metadata.achievements.map(a => + { + const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined; + const ach: FrontEndGameTypeDetailedAchievement = { + id: a.badge_id ?? String(a.ra_id) ?? 'unknown', + title: a.title ?? "Unknown", + badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined, + date: earned?.date, + date_hardcode: earned?.date_hardcode, + description: a.description ?? undefined, + display_order: a.display_order ?? 0, + type: a.type ?? undefined + }; + + return ach; + }).sort((a, b) => a.display_order - b.display_order), + total: rom.merged_ra_metadata.achievements.length + }; + } + return detailed; + } + + async setup () + { + this.isSteamDeck = isSteamDeckGameMode(); + await this.updateClient(); + } + + load (ctx: PluginContextType) + { + 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')) + { + + 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 ?? ''] + }, throwOnError: true + }); + games.push(...rommGames.data.items.map(g => + { + return this.convertRomToFrontend(g); + })); + } + }); + + ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => + { + if (service !== 'romm') return; + await this.updateClient(); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => + { + if (source !== 'romm') return; + + const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); + if (rom.data) + { + const romGame = await this.convertRomToFrontendDetailed(rom.data); + if (localGame) + { + return { + ...romGame, + ...localGame, + }; + } + return romGame; + } + + return undefined; + }); + + ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'romm') return; + + const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; + const rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data; + const rommAddress = config.get('rommAddress'); + if (!rommAddress) throw new Error("Romm Address Not Defined"); + + const files = await Promise.all(rom.files.map(async f => + { + const file: DownloadFileEntry = { + url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + file_name: f.file_name, + file_path: path.join(config.get('downloadPath'), f.file_path), + size: f.file_size_bytes, + sha1: f.sha1_hash ?? undefined + }; + return file; + })); + + const info: DownloadInfo = { + platform: { + slug: rommPlatform.slug, + name: rommPlatform.name, + family_name: rommPlatform.family_name ?? undefined + }, + coverUrl: `${rommAddress}${rom.path_cover_large}`, + screenshotUrls: rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`), + last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : undefined, + igdb_id: rom.igdb_id ?? undefined, + ra_id: rom.ra_id ?? undefined, + summary: rom.summary ?? undefined, + name: rom.name ?? "Unknown", + path_fs: path.join(rom.fs_path, rom.fs_name), + source_id: String(rom.id), + slug: rom.slug ?? undefined, + system_slug: rommPlatform.slug, + metadata: rom.metadatum, + files, + auth: await this.getAuthToken() + }; + + return info; + + }); + + ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => + { + const files: DownloadFileEntry[] = []; + const allRommPlatforms = await this.getAllRommPlatforms(); + + const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r); + + for (const rommPlatform of rommPlatforms) + { + const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data); + if (firmwares) + { + for (const firmware of firmwares) + { + const firmwarePath = path.join(biosFolder, firmware.file_name); + const exists = await fs.exists(firmwarePath); + + if (exists && await hashFile(firmwarePath, 'sha1')) + { + return; + } + + files.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) }); + } + } + } + + if (files.length > 0) return { files, auth: await this.getAuthToken() }; + }); + + ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => + { + const rommPlatforms = await this.getAllRommPlatforms(); + if (rommPlatforms) + { + const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); + if (rommPlatform) + { + const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); + if (rommGames.data) + { + games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); + } + } + } + }); + + ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => + { + + const rommPlatforms = await this.getAllRommPlatforms(); + const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); + if (rommPlatforms) + { + const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id); + if (platformIds.length > 0) + { + const rommGames = await getRomsApiRomsGet({ + query: { + platform_ids: platformIds + } + }); + + let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size); + + for (const slug of systemsRommSlugSet) + { + const systemRommGames = rommGames.data?.items.filter(g => slug === g.platform_slug).map(g => + { + return this.convertRomToFrontend(g); + }).slice(0, gamesPerSystem) ?? []; + games.push(...systemRommGames); + } + } + } + }); + + ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'romm') return; + const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); + if (rommPlatform) + { + const platform: FrontEndPlatformType = { + slug: rommPlatform.slug, + name: rommPlatform.display_name, + family_name: rommPlatform.family_name, + path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`, + game_count: rommPlatform.rom_count, + updated_at: new Date(rommPlatform.updated_at), + id: { source: 'romm', id: String(rommPlatform.id) }, + paths_screenshots: [], + hasLocal: false + }; + + return platform; + } + }); + + ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) => + { + const rommPlatforms = await this.getAllRommPlatforms(); + if (rommPlatforms) + { + const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => + { + const screenshots: string[] = []; + const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data); + if (rommGames) + { + const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`); + if (rommScreenshots) + screenshots.push(...rommScreenshots); + } + + const platform: FrontEndPlatformType = { + slug: p.slug, + name: p.display_name, + family_name: p.family_name, + path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`, + game_count: p.rom_count, + updated_at: new Date(p.updated_at), + id: { source: 'romm', id: String(p.id) }, + hasLocal: false, + paths_screenshots: screenshots + }; + + return platform; + })); + + + platforms.push(...frontEndPlatforms); + } + }); + + ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'romm') return false; + const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); + if (resp.error) console.error(resp.error); + return resp.response.ok; + }); + + ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => + { + const rommCollections = await getCollectionsApiCollectionsGet(); + if (rommCollections.response.ok && rommCollections.data) + { + collections.push(...rommCollections.data.map(c => + { + const collection: FrontEndCollection = { + id: { source: 'romm', id: String(c.id) }, + name: c.name, + description: c.description, + game_count: c.rom_count, + path_platform_cover: `/api/romm/image/romm${this.isSteamDeck ? c.path_covers_small ?? c.path_covers_small[0] : c.path_cover_large ?? c.path_covers_large[0]}` + }; + + return collection; + })); + } + }); + + ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'romm') return; + const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); + if (collection.data) + { + const col: FrontEndCollection = { + id: { source: 'romm', id: String(id) }, + name: collection.data.name, + description: collection.data.owner_username, + path_platform_cover: `/api/romm/image/romm${this.isSteamDeck ? collection.data.path_covers_small ?? collection.data.path_covers_small[0] : collection.data.path_cover_large ?? collection.data.path_covers_large[0]}`, + game_count: collection.data.rom_count + }; + return col; + } + + }); + + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'romm') return; + const platforms = await this.getAllRommPlatforms(); + return platforms.find(p => p.id === Number(id)); + }); + } +} \ 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 9959289..90392f1 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -27,7 +27,8 @@ export class PluginManager if (plugin.setup) await plugin.setup(); this.plugins[description.name] = { enabled: !config.get('disabledPlugins').includes(description.name), - loaded: false, plugin: plugin, + loaded: false, + plugin: plugin, source: source, description: description }; diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 9226d82..d3b9667 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -1,25 +1,26 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; +import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; -import path from "node:path"; export default async function register (pluginManager: PluginManager) { - const plugins: (PluginDescriptionType & { main: string; root: string; })[] = [ - { ...pcsx2, root: './builtin/emulators/com.simeonradivoev.gameflow.pcsx2' } + const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ + { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, + { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; await Promise.all(plugins.map(async (pluginPackage) => { - const file = await import(`./${path.join(pluginPackage.root, pluginPackage.main)}`); + const file = await pluginPackage.load(); if (file.default && typeof file.default === 'function') { const pluginInstance = new file.default(); - const plugin = await PluginSchema.parseAsync(pluginInstance); + await PluginSchema.parseAsync(pluginInstance); const description = await PluginDescriptionSchema.parseAsync(pluginPackage); - pluginManager.register(plugin, description, 'builtin'); + pluginManager.register(pluginInstance, description, 'builtin'); } })); } \ No newline at end of file diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 6d505f3..afaa5fe 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -54,7 +54,6 @@ export async function getRelevantEmulators () const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { const execPaths = await findExecsByName(emulator); - const validExecPath = execPaths.find(e => e.exists); let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -63,7 +62,7 @@ export async function getRelevantEmulators () platform = platformLookup.get(validSystemSlug.system)?.platform_id; } const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!))); - if (validExecPath) + if (execPaths.some(p => p.exists)) { systems.forEach(s => platformViability.set(s, true)); } @@ -71,10 +70,10 @@ export async function getRelevantEmulators () const em: FrontEndEmulator & { isCritical: boolean; } = { name: emulator, logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', - systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), + 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, - validSource: validExecPath + validSources: execPaths }; return em; @@ -82,11 +81,12 @@ export async function getRelevantEmulators () finalEmulators.push({ name: 'EMULATORJS', - validSource: { binPath: `${SERVER_URL(host)}`, type: 'js', exists: true }, + validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], gameCount: 0, isCritical: false, + description: "Embedded Emulator. Uses Retroarch Cores" }); return finalEmulators.map(e => diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 753eda3..1340731 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -4,19 +4,15 @@ import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; -export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: { - id: string; - name: string; - icon: string; -}[]) +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) { - let execPath: EmulatorSourceEntryType | undefined; + 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); - if (allExecs.length > 0) execPath = allExecs[0]; + execPaths.push(...allExecs); } const em: FrontEndEmulator = { @@ -24,7 +20,7 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT logo: emulator.logo, systems, gameCount, - validSource: execPath, + validSources: execPaths, integration: findEmulatorPluginIntegration(emulator.name) }; diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 9247823..ae0181f 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -113,7 +113,7 @@ export async function getAllStoreEmulatorPackages () return emulatesParsed; } -export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType) +export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType): Promise { const systems = await Promise.all(emulator.systems.map(async system => { @@ -125,7 +125,7 @@ export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPacka let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; - return { id: system, romm_slug: rommSystem?.sourceSlug, name: esSystem?.fullname ?? system, icon: icon }; + return { id: system, romm_slug: rommSystem?.sourceSlug ?? undefined, name: esSystem?.fullname ?? system, iconUrl: icon } satisfies EmulatorSystem; })); return systems; diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index ab5ba97..d29746d 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -48,7 +48,12 @@ export const store = new Elysia({ prefix: '/api/store' }) if (query.missing) { - frontEndEmulators = frontEndEmulators.filter(e => !e.validSource); + frontEndEmulators = frontEndEmulators.filter(e => + { + if (e.validSources.some(s => s.exists)) return false; + if (query.related && e.name === query.related) return false; + return true; + }); } if (query.orderBy === 'importance') @@ -72,7 +77,8 @@ export const store = new Elysia({ prefix: '/api/store' }) query: z.object({ limit: z.coerce.number().optional(), missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), - orderBy: z.enum(['name', 'recently_updated', 'importance']).optional() + orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(), + related: z.string().optional() }) }) .get('/games/featured', async () => @@ -112,7 +118,6 @@ export const store = new Elysia({ prefix: '/api/store' }) const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const validExec = execPaths.find(p => p.exists); const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; @@ -120,7 +125,7 @@ export const store = new Elysia({ prefix: '/api/store' }) name: emulatorPackage.name, description: emulatorPackage.description, systems, - validSource: validExec, + validSources: execPaths, screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), gameCount: 0, homepage: emulatorPackage.homepage, diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 52f1dd5..53d2c59 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -7,10 +7,10 @@ import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; -import { DirSchema } from "@/shared/constants"; +import { DirSchema, SystemInfoSchema } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; -import si from 'systeminformation'; +import si, { battery } from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; export const system = new Elysia({ prefix: '/api/system' }) @@ -61,6 +61,33 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); }) + .ws('/info/system', { + response: SystemInfoSchema, + async open (ws) + { + const valuesObject = { + battery: 'percent, isCharging, acConnected, hasBattery' + }; + + const battery = await si.battery(); + const wifi = await si.wifiConnections(); + const bluetooth = await si.bluetoothDevices(); + ws.send({ + battery: battery, + wifiConnections: wifi, + bluetoothDevices: bluetooth + }, true); + + (ws.data as any).observer = si.observe(valuesObject, 1000 * 30, (data) => + { + ws.send(data); + }); + }, + close (ws) + { + clearInterval((ws.data as any).observer); + } + }) .get('/info/battery', async () => { return si.battery(); diff --git a/src/bun/server.ts b/src/bun/server.ts index d9ae6b0..945be5b 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -3,7 +3,6 @@ import { host } from "./utils/host"; import { appPath } from "./utils"; import Elysia from "elysia"; import cors from "@elysiajs/cors"; -import staticPlugin from "@elysiajs/static"; export function RunBunServer () { @@ -23,16 +22,13 @@ export function RunBunServer () { return Bun.file(appPath('./dist/emulatorjs/index.html')); }) - .use(staticPlugin({ - indexHTML: false, - assets: appPath("./dist"), - prefix: "/", - alwaysStatic: true - })); + .get("/*", ({ params }) => Bun.file(appPath(`./dist/${params["*"]}`))); return new Promise((resolve) => { - server.onStart(() => resolve(server)) - .listen({ port: SERVER_PORT, hostname: host, development: true }, console.log); + server.listen({ port: SERVER_PORT, hostname: host, development: true }, async ({ hostname, port }) => + { + resolve(server); + }); }); } \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index f7a2e4e..02b8d13 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -74,17 +74,20 @@ export async function openExternal (target: string) } } -export function hashFile (path: string, algorithm: "sha1" | "md5"): Promise +export async function hashFile (path: string, algorithm: Bun.SupportedCryptoAlgorithms) { - return new Promise((resolve, reject) => - { - const hash = createHash(algorithm); - const stream = createReadStream(path); + const hasher = new Bun.CryptoHasher(algorithm); + const stream = Bun.file(path).stream(); + const reader = stream.getReader(); - stream.on("data", (data) => hash.update(data)); - stream.on("end", () => resolve(hash.digest("hex"))); - stream.on("error", reject); - }); + while (true) + { + const { done, value } = await reader.read(); + if (done) break; + hasher.update(value); + } + + return hasher.digest('hex'); } export class SeededRandom @@ -118,19 +121,20 @@ export function toggleElementInConfig (id: KeysWithValueAssignableTo= 0) { config.set('disabledPlugins', disabled.toSpliced(index, 1)); } + } else + { + const index = disabled.indexOf(element); + if (index < 0) + { + config.set('disabledPlugins', disabled.concat(element)); + } + } } diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index e239905..ea0a6b2 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -5,14 +5,6 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; -export interface FileEntry -{ - url: URL; - file_path: string; - file_name: string; - size?: number; -} - export interface ProgressStats { progress: number; @@ -20,7 +12,7 @@ export interface ProgressStats interface TmpDownloadMetadata { - files: FileEntry[]; + files: DownloadFileEntry[]; } /** @@ -29,11 +21,11 @@ interface TmpDownloadMetadata */ export class Downloader { - files: FileEntry[]; + files: DownloadFileEntry[]; headers?: Record; onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; - activeFile?: FileEntry; + activeFile?: DownloadFileEntry; downloadPath: string; id: string; tmpPath: string; @@ -41,7 +33,7 @@ export class Downloader constructor( id: string, - files: FileEntry[], + files: DownloadFileEntry[], downloadPath: string, init?: { headers?: Record, onProgress?: (stats: ProgressStats) => void; diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 1482f6f..31ea52a 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -119,14 +119,14 @@ export function AnimatedBackground (data: { > {!data.scrolling &&
+
{blur && finalLastBackgroundUrl && } {finalBackgroundUrl ? e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")} + onLoad={e => e.currentTarget.classList.add("animate-bg-zoom")} > : <>
}
diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index d29d626..2744e8e 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,9 +1,9 @@ -import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { useEffect } from "react"; export function AutoFocus (data: { parentKey?: string; - focus: () => void; + focus: (focusDetails?: FocusDetails | undefined) => void; force?: boolean; delay?: number; }) @@ -16,10 +16,10 @@ export function AutoFocus (data: { { if (data.delay) { - delayTimeout = window.setTimeout(() => data.focus(), data.delay); + delayTimeout = window.setTimeout(() => data.focus({ instant: true }), data.delay); } else { - data.focus(); + data.focus({ instant: true }); } } diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index 1d27805..7f5f2dc 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -7,7 +7,7 @@ import useActiveControl from "../scripts/gamepads"; export function GameCardSkeleton () { return ( -
  • +
  • @@ -22,7 +22,6 @@ export type GameCardFocusHandler = (id: string, node: HTMLElement, details: Focu export interface GameCardParams { title: string; - type?: string; subtitle: string | JSX.Element; preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); srcset?: string; @@ -43,7 +42,7 @@ export default function CardElement (data: GameCardParams & InteractParams) focusKey: data.focusKey, onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), onEnterPress: () => data.onAction?.(), - onBlur: () => data.onBlur?.(data.id) + onBlur: () => data.onBlur?.(data.id), }); const { isPointer } = useActiveControl(); @@ -76,7 +75,7 @@ export default function CardElement (data: GameCardParams & InteractParams) classNames({ "h-full": typeof data.preview === "string" }) )}> {typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )} diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 306d42d..0518d2c 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -16,6 +16,42 @@ export interface GameMetaExtra extends GameMeta focusKey: string; } +function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) +{ + let preview: GameCardParams['preview'] = data.game.preview; + if (!preview && data.game.previewUrl) + { + preview = data.game.previewUrl; + } + + const handleAction = () => + { + data.game.onSelect?.(); + data.onAction?.(); + }; + useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); + + return ( + + { + data.game.onFocus?.(details); + data.onFocus?.(id, node, details); + }} + onAction={handleAction} + preview={preview} + badges={data.game.badges} + id={data.game.id} + /> + ); +} + export function CardList (data: { id: string; type?: string; @@ -30,46 +66,10 @@ export function CardList (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id, + forceFocus: true, + autoRestoreFocus: true }); - function BuildCard (g: GameMetaExtra, i: number) - { - let preview: GameCardParams['preview'] = g.preview; - if (!preview && g.previewUrl) - { - preview = g.previewUrl; - } - - const handleAction = () => - { - g.onSelect?.(); - data.onSelectGame?.(g.id); - }; - useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); - - return ( - - { - g.onFocus?.(details); - data.onGameFocus?.(id, node, details); - }} - onAction={handleAction} - preview={preview} - badges={g.badges} - id={g.id} - /> - ); - } - return (
      - {data.games.map(BuildCard)} + {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} {data.finalElement}
    diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 208f2f9..8bb02a4 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,9 +1,9 @@ import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; import { CardList, GameMetaExtra } from "./CardList"; import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; +import { Router } from ".."; export default function CollectionList (data: { id: string, @@ -14,12 +14,16 @@ export default function CollectionList (data: { saveChildFocus?: 'session' | 'local'; }) { - const navigate = useNavigate(); - const { data: collections } = useSuspenseQuery(getCollectionsQuery()); + const { data: collections } = useSuspenseQuery(getCollectionsQuery); - const handleDefaultSelect = (id: string) => + const handleDefaultSelect = (gameId: string) => { - navigate({ to: `/collection/${id}` }); + const [source, id] = gameId.split('@'); + Router.navigate({ + to: `/collection/$source/$id`, + params: { source, id }, + search: { countHint: collections.find(c => c.id.id === id && c.id.source === source)?.game_count } + }); }; return ( @@ -28,16 +32,16 @@ export default function CollectionList (data: { id={data.id} className={data.className} saveChildFocus={data.saveChildFocus} - games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at)) + games={collections .map((g) => ({ - id: String(g.id), + id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - subtitle: g.owner_username, - previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_small[0]}`, + subtitle: "", + previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ - {g.rom_count} + {g.game_count} ], } satisfies GameMetaExtra))} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 1b31049..35aae6f 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,6 +1,6 @@ import { AnimatedBackground } from './AnimatedBackground'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { HeaderUI } from './Header'; +import { HeaderUI, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; import { JSX, Suspense, useEffect } from 'react'; @@ -9,77 +9,82 @@ import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; -import { HandleGoBack } from '../scripts/utils'; +import { HandleGoBack, useStickyDataAttr } from '../scripts/utils'; +import LoadingCardList from './LoadingCardList'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { gameQuery } from '../scripts/queries/romm'; export interface CollectionsDetailParams { id?: string; setBackground?: (url: string) => void; filters?: GameListFilterType; + builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>; headerTitle?: JSX.Element; title?: JSX.Element; footer?: JSX.Element; focus?: string; + countHit?: number; } export function CollectionsDetail (data: CollectionsDetailParams) { - const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`; + const builtData = useQuery({ + queryKey: ['filter', data.id], queryFn: async () => + { + return data.builder?.() ?? { filter: data.filters, title: data.title }; + } + }); + const queryClient = useQueryClient(); + const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`; const { ref, focusSelf } = useFocusable({ focusKey, - preferredChildFocusKey: `${focusKey}-list`, + preferredChildFocusKey: `${focusKey}-list` }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - const handleScroll: GameCardFocusHandler = (id, node, details) => + const handleScroll: GameCardFocusHandler = (cardId, node, details) => { + + const [source, id] = cardId.split('@'); + queryClient.prefetchQuery(gameQuery(source, id)); + if (!(details.nativeEvent instanceof MouseEvent)) { node.scrollIntoView({ block: 'center', behavior: details.instant ? 'instant' : 'smooth' }); } }; - useEffect(() => - { - if (data.focus) - setFocus(data.focus, { instant: true }); - }, [data.focus]); - - useEffect(() => - { - return () => setFocus(''); - }, []); - return ( - -
    - }, { id: "filter", icon: }]} /> -
    -
    -
    - {data.title} - +
    + }, { id: "filter", icon: }]} ref={ref} /> +
    +
    + {builtData.data?.filter && data.title} + {(builtData.data?.filter || (!data.filters && !data.builder)) && }> - - - + + } +
    +
    +
    -
    +
    {data.footer}
    - +
    ); } \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index c32ccb7..230da58 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -10,8 +10,9 @@ import { FOCUS_KEYS } from "../scripts/types"; export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; }) { const context = useContext(ContextDialogContext); - return
      + return
        {data.options?.map(o => )} +
        {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
      ; } @@ -32,12 +33,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class trackChildren: typeof data.content !== 'string' }); const colors = { - primary: "active:bg-primary control-pointer:hover:bg-primary control-pointer:hover:text-primary-content focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content", - secondary: "active:bg-secondary control-pointer:hover:bg-secondary control-pointer:hover:text-secondary-content focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content", - accent: "active:bg-accent control-pointer:hover:bg-accent control-pointer:hover:text-accent-content focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content", - info: "active:bg-info control-pointer:hover:bg-info control-pointer:hover:text-info-content focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content", - warning: "active:bg-warning control-pointer:hover:bg-warning control-pointer:hover:text-warning-content focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content", - error: "active:bg-error control-pointer:hover:bg-error control-pointer:hover:text-error-content focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content" + primary: "active:bg-primary active:text-primary-content focusable-primary in-data-[selected=true]:bg-primary in-data-[selected=true]:text-primary-content", + secondary: "active:bg-secondary active:text-secondary-content focusable-secondary in-data-[selected=true]:bg-secondary in-data-[selected=true]:text-secondary-content", + accent: "active:bg-accent active:text-accent-content focusable-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-content", + info: "active:bg-info active:text-info-content focusable-info in-data-[selected=true]:bg-info in-data-[selected=true]:text-info-content", + warning: "active:bg-warning active:text-warning-content focusable-warning in-data-[selected=true]:bg-warning in-data-[selected=true]:text-warning-content", + error: "active:bg-error active:text-error-content focusable-error in-data-[selected=true]:bg-error in-data-[selected=true]:text-error-content" }; if (data.shortcuts) { @@ -45,13 +46,14 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class } return
    • + twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> -
      + "in-focused:bg-base-content in-focused:text-base-100")}> {data.icon} {data.content}
      @@ -65,6 +67,7 @@ export interface DialogEntry content: string | JSX.Element; icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; + selected?: boolean; action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; shortcuts?: Shortcut[]; } diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index b0f6608..689900d 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -3,7 +3,7 @@ import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; import { useContext, useRef, useState } from "react"; import path from "pathe"; -import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; +import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { DirType } from "@/shared/constants"; import classNames from "classnames"; @@ -44,13 +44,13 @@ function List (data: { { const fullPath = path.join(f.parentPath, f.name); const isDefaultPath = fullPath === startingPath; - let icon = ; + let icon = ; if (isDefaultPath) { - icon = ; + icon = ; } else if (!f.isDirectory) { - icon = <>; + icon = ; } const shortcuts: Shortcut[] = []; let action: () => void; @@ -201,7 +201,7 @@ function ListWithDrives (data: {
    -
    +
    diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 8d86dad..d998f80 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,6 +1,6 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { GameListFilterType, RPC_URL } from "@shared/constants"; +import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; @@ -24,7 +24,7 @@ export interface GameListParams export function GameList (data: GameListParams) { - const games = useSuspenseQuery(allGamesQuery(data.filters)); + const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime }); const navigator = useNavigate(); const blur = useLocalSetting('backgroundBlur'); const backgroundContext = useContext(AnimatedBackgroundContext); @@ -80,7 +80,7 @@ export function GameList (data: GameListParams) platformUrl.searchParams.set('width', "64"); return { - id: `game-${g.id.source}-${g.id.id}`, + id: `${g.id.source}@${g.id.id}`, focusKey: g.slug ?? `game-${g.id}`, title: g.name ?? "", subtitle: ( diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index f79d348..1dad439 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -14,6 +14,7 @@ import Bell, Bluetooth, Clock, + Plug, Settings, Wifi, WifiHigh, @@ -21,16 +22,18 @@ import WifiZero, } from "lucide-react"; import { RoundButton } from "./RoundButton"; -import { useQuery } from "@tanstack/react-query"; -import { RPC_URL } from "../../shared/constants"; -import { JSX, RefObject, useEffect, useRef, useState } from "react"; +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 { rommUserQuery } from "../scripts/queries/romm"; +import { rommLoggedInQuery, rommUserQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; +import { da } from "zod/v4/locales"; +import { SystemInfoContext } from "../scripts/contexts"; function HeaderAvatar (data: { id: string; @@ -76,7 +79,7 @@ export interface HeaderAccount { id: string; preview?: string | JSX.Element; - status?: "status-error" | "status-success" | "status-neutral"; + className?: string; type?: "base" | "primary" | "secondary" | "accent"; locked?: boolean; action?: () => void; @@ -128,26 +131,19 @@ function ClockStatus () function BluetoothStatus () { - const { data: bluetooth } = useQuery({ - queryKey: ['wifi'], - queryFn: () => systemApi.api.system.info.bluetooth.get().then(d => d.data), - refetchInterval: 3000 - }); - return bluetooth && bluetooth.find(b => b.connected) &&
    + const systemContext = useContext(SystemInfoContext); + + return systemContext?.bluetoothDevices.find(b => b.connected) &&
    ; } function WiFiStatus () { - const { data: wifi, isLoading } = useQuery({ - queryKey: ['wifi'], - queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data), - refetchInterval: 3000 - }); + const systemContext = useContext(SystemInfoContext); - return (!!wifi && wifi.length > 0) || isLoading ?
    - {wifi?.map(w => + return systemContext && systemContext.wifiConnections.length > 0 ?
    + {systemContext.wifiConnections.map(w => { const className = "w-6 h-6"; let icon = ; @@ -170,46 +166,44 @@ function WiFiStatus () function BatteryStatus () { - const { data: battery } = useQuery({ - queryKey: ['battery'], - queryFn: () => systemApi.api.system.info.battery.get().then(d => d.data), - refetchInterval: 3000 - }); - const batteryClassName = "w-6 h-6"; + const systemContext = useContext(SystemInfoContext); + + const batteryClassName = "md:size-10 sm:size-6"; let batteryIcon = ; - if (battery?.isCharging || battery?.acConnected) + if (systemContext) { - batteryIcon = ; - } else if (battery?.percent) - { - if (battery.percent < 5) + if (systemContext.battery.isCharging || systemContext.battery.acConnected) { - batteryIcon = ; - } - else if (battery.percent < 15) + batteryIcon = ; + } else if (systemContext.battery.percent) { - batteryIcon = ; - } else if (battery.percent < 50) - { - batteryIcon = ; + if (systemContext.battery.percent < 5) + { + batteryIcon = ; + } + else if (systemContext.battery.percent < 15) + { + batteryIcon = ; + } else if (systemContext.battery.percent < 50) + { + batteryIcon = ; + } } } - return !!battery && battery.hasBattery &&
    + return !!systemContext?.battery.hasBattery &&
    {batteryIcon} - {battery?.percent} % + {systemContext.battery?.percent} %
    ; } export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { - const rommUser = useQuery({ - ...rommUserQuery(), - refetchOnWindowFocus: false, - retry: 1 - }); + const rommUser = useQuery({ ...rommLoggedInQuery, placeholderData: keepPreviousData }); const twitchStatus = useQuery({ - ...twitchLoginVerificationQuery, refetchOnWindowFocus: false, - retry: 1 + ...twitchLoginVerificationQuery, + refetchOnWindowFocus: false, + retry: 1, + placeholderData: keepPreviousData }); const { ref } = useFocusable({ focusKey: 'accounts' }); @@ -217,15 +211,15 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); - if (rommUser.data) + if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ - id: 'romm', preview: `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`, + id: 'romm', preview: `https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg`, action: () => { Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); }, - status: rommUser.data ? "status-success" : 'status-error', + className: rommUser.data?.hasLogin && !rommUser.isError ? undefined : "border-error", type: 'secondary' }); } @@ -248,6 +242,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) id={`account-${a.id}`} locked={a.locked} preview={a.preview} + className={a.className} onSelect={a.action} />)}
    ; @@ -303,7 +298,7 @@ export function HeaderUI (data: HeaderUIParams) > {data.title} - , id: "settings", action: goToSettings, external: true }]} /> + , id: "settings", action: goToSettings, external: true }]} /> ); diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index cdcde29..5d2cb03 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,6 +1,7 @@ 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) { @@ -17,7 +18,11 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr onEnterPress: handleAction }); + + const { ref: intersct } = useIntersectionObserver({ + initialIsIntersecting: true, + rootMargin: "20%", onChange: (isIntersecting, entry) => { if (isIntersecting) diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index a48b491..e25dc26 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -1,15 +1,27 @@ -import classNames from 'classnames'; -import { GameCardSkeleton } from './CardElement'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { twMerge } from 'tailwind-merge'; +import CardElement from './CardElement'; -export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; }) + +export default function LoadingCardList (data: { id: string, placeholderCount: number, grid?: boolean; className?: string; }) { + + const { ref, focusKey } = useFocusable({ + focusKey: data.id, + forceFocus: true, + autoRestoreFocus: true + }); + return (
      { @@ -18,7 +30,28 @@ export default function LoadingCardList (data: { placeholderCount: number, grid? }} style={{ scrollbarWidth: "none" }} > - {new Array(data.placeholderCount).fill(1).map((p, i) => )} + + {new Array(data.placeholderCount).fill(1).map((g, i) => + { + return + { + + }} + preview={
      + +
      } + id={`loading-card-${i}`} + />; + })} +
      +
    ); } diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index b29f25a..48af4a3 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -36,7 +36,7 @@ export function PlatformsList (data: { const handleDefaultSelect = (source: string, id: string) => { - navigate({ to: `/platform/${source}/${id}` }); + navigate({ to: `/platform/$source/$id`, params: { source, id }, search: { countHint: platforms.find(p => p.id.id === id && p.id.source === source)?.game_count } }); }; const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime()) diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index 246a876..3ff22f9 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -34,10 +34,10 @@ export default function StatList (data: { let content: any = undefined; if (s.content instanceof Array) { - content =
    {s.content.map((c, ci) => {c})}
    ; + 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/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 6c914c0..5a3b995 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -6,11 +6,11 @@ import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; -import { Clock, Download, EllipsisVertical, PackageOpen, Play, TriangleAlert } from "lucide-react"; +import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; -export default function MainActions (data: { game: FrontEndGameTypeDetailed, source: string, id: string; }) +export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { const installMut = useMutation(installMutation(data.source, data.id)); const playMut = useMutation({ @@ -29,7 +29,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); const [commands, setCommands] = useState(undefined); - const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game.source ?? data.game.id.source}-${data.game.source_id ?? data.game.id.id}-preferred-command`, undefined); + const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); const validCommands = commands ? commands.filter(c => c.valid) : []; const validDefaultCommand = commands?.find(c => @@ -41,7 +41,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou useEffect(() => { - const sub = rommApi.api.romm.status({ source: data.game.id.source })({ id: data.game.id.id }).subscribe(); + const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); ws.current = sub.ws; sub.subscribe((e) => @@ -69,7 +69,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou sub.close(); ws.current = undefined; }; - }, [data.game.id]); + }, [data.source, data.id]); let progressIcon: JSX.Element | undefined = undefined; switch (status) @@ -101,7 +101,7 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou 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.game.id.source, id: data.game.id.id, command_id: cmd.id }); + playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); } }; @@ -142,20 +142,31 @@ export default function MainActions (data: { game: FrontEndGameTypeDetailed, sou } else { + let icon = ; + if (status === 'install') + { + icon = ; + } else if (status === 'present') + { + icon = ; + } mainButton = { - if (status === 'install') + switch (status) { - installMut.mutate(); + case 'present': + case 'install': + installMut.mutate(); + break; } }} tooltip={details ?? status} type='primary' id="mainAction"> - {status === 'install' ? : } + {icon} ; } diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 071b9f6..083b6c4 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -44,6 +44,7 @@ export function OptionDropdown (data: { content: v, id: String(i), type: 'primary', + selected: data.value === v, action: () => { data.onChange?.(v); diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 78fd7d4..ccf81cb 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -45,7 +45,7 @@ export function StoreEmulatorCard (data: { ref={ref} role="button" tabIndex={0} - data-installed={!!data.emulator.validSource} + 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)} > @@ -62,10 +62,10 @@ export function StoreEmulatorCard (data: {

    {data.emulator.name}

      - {data.emulator.systems.map(({ id, name, icon }) => + {data.emulator.systems.map(({ id, name, iconUrl }) => { return
      - {!!icon && } + {!!iconUrl && }

      {name}

      ; })} @@ -75,17 +75,19 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
    + {!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    } - {!!data.emulator.validSource &&
    -
    - {emulatorStatusIcons[data.emulator.validSource?.type ?? '']} -
    -
    } + {data.emulator.validSources.slice(0, 3).map(s => + { + return
    +
    + {emulatorStatusIcons[s.type]} +
    +
    ; + })} {isMouse && <> - }
    diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index d730600..5988dfd 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -18,7 +18,6 @@ import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emul import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAboutRouteImport } from './../routes/settings/about' -import { Route as CollectionIdRouteImport } from './../routes/collection.$id' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' @@ -27,6 +26,7 @@ import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$sour import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id' import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' +import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' const GamesRoute = GamesRouteImport.update({ @@ -74,11 +74,6 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRouteRoute, } as any) -const CollectionIdRoute = CollectionIdRouteImport.update({ - id: '/collection/$id', - path: '/collection/$id', - getParentRoute: () => rootRouteImport, -} as any) const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ id: '/store/tab', path: '/store/tab', @@ -119,6 +114,11 @@ const EmbeddedSourceIdRoute = EmbeddedSourceIdRouteImport.update({ path: '/embedded/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({ + id: '/collection/$source/$id', + path: '/collection/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ id: '/store/details/emulator/$id', path: '/store/details/emulator/$id', @@ -130,13 +130,13 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -150,13 +150,13 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -172,13 +172,13 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren - '/collection/$id': typeof CollectionIdRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -195,13 +195,13 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -215,13 +215,13 @@ export interface FileRouteTypes { | '/' | '/settings' | '/games' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -236,13 +236,13 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' - | '/collection/$id' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -258,7 +258,7 @@ export interface RootRouteChildren { SettingsRouteRoute: typeof SettingsRouteRouteWithChildren GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren - CollectionIdRoute: typeof CollectionIdRoute + CollectionSourceIdRoute: typeof CollectionSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute @@ -331,13 +331,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRouteRoute } - '/collection/$id': { - id: '/collection/$id' - path: '/collection/$id' - fullPath: '/collection/$id' - preLoaderRoute: typeof CollectionIdRouteImport - parentRoute: typeof rootRouteImport - } '/store/tab': { id: '/store/tab' path: '/store/tab' @@ -394,6 +387,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EmbeddedSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/collection/$source/$id': { + id: '/collection/$source/$id' + path: '/collection/$source/$id' + fullPath: '/collection/$source/$id' + preLoaderRoute: typeof CollectionSourceIdRouteImport + parentRoute: typeof rootRouteImport + } '/store/details/emulator/$id': { id: '/store/details/emulator/$id' path: '/store/details/emulator/$id' @@ -447,7 +447,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRouteRoute: SettingsRouteRouteWithChildren, GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, - CollectionIdRoute: CollectionIdRoute, + CollectionSourceIdRoute: CollectionSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute, 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.css b/src/mainview/index.css index 3bad22c..eb09eb3 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -365,8 +365,10 @@ body { .bg-noise { position: absolute; - width: 100%; - height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: -1; background-image: url("https://momentsingraphics.de/Media/BlueNoise/BlueNoise470.png"); mix-blend-mode: color-dodge; @@ -375,8 +377,10 @@ body { .bg-dots { position: absolute; - width: 100%; - height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: -1; background-image: radial-gradient(var(--color-neutral) 0.1rem, transparent 0.1rem); background-size: 2rem 2rem; @@ -421,11 +425,11 @@ body { html:active-view-transition-type(slide-up) { &::view-transition-old(root) { - animation: fade-out 300ms ease-in forwards; + animation: fade-out 200ms ease-in forwards; } &::view-transition-new(root) { - animation: slide-up 300ms ease-in-out forwards; + animation: slide-up 200ms ease-out forwards; } } diff --git a/src/mainview/index.html b/src/mainview/index.html index 43c30a6..a4bb3db 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -18,7 +18,9 @@ GameFlow + +
    diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index 1256fd2..c76e8e9 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -45,7 +45,7 @@ export const Router = createRouter({ history: hashHistory, defaultPreload: "intent", context: { queryClient }, - scrollRestoration: true, + scrollRestoration: false, defaultNotFoundComponent: NotFound, defaultPendingMs: 300, defaultErrorComponent: Error, @@ -67,6 +67,7 @@ export const Router = createRouter({ }); const focusMap = new Map(); +export const focusQueue: string[] = []; Router.history.subscribe((op) => { @@ -77,7 +78,8 @@ Router.history.subscribe((op) => { if (focusMap.has(op.location.state.__TSR_index)) { - setFocus(focusMap.get(op.location.state.__TSR_index)!); + focusQueue.pop(); + focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); focusMap.delete(op.location.state.__TSR_index); } } diff --git a/src/mainview/preload.tsx b/src/mainview/preload.tsx new file mode 100644 index 0000000..c95a05b --- /dev/null +++ b/src/mainview/preload.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; + +const rootElement = document.getElementById("preload")!; + +if (!rootElement.innerHTML) +{ + const root = createRoot(rootElement); + root.render( + +
    + +
    +
    +
    + Loading Gameflow +
    +
    , + ); +} diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 243d778..bdee113 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,7 +4,10 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { SystemInfoContext } from "../scripts/contexts"; +import { SystemInfoType } from "@/shared/constants"; +import { systemApi } from "../scripts/clientApi"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -31,11 +34,25 @@ function RootComponent () }, [theme]); + const [systemInfo, setSystemInfo] = useState(); + useEffect(() => + { + const sub = systemApi.api.system.info.system.subscribe(); + sub.subscribe(({ data }) => + { + setSystemInfo(data); + }); + + document.documentElement.dataset.loaded = "true"; + }, []); + return (
    - + + + - + {/*import.meta.env.DEV && !isMobile && <> diff --git a/src/mainview/routes/collection.$id.tsx b/src/mainview/routes/collection.$id.tsx deleted file mode 100644 index 6df7530..0000000 --- a/src/mainview/routes/collection.$id.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { CollectionsDetail } from '../components/CollectionsDetail'; -import { getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen'; -import { DefaultRommStaleTime } from '@shared/constants'; -import { useQuery } from '@tanstack/react-query'; -import { useContext } from 'react'; -import { AnimatedBackgroundContext } from '../scripts/contexts'; -import { getCollectionQuery } from '@queries/romm'; - -export const Route = createFileRoute('/collection/$id')({ - component: RouteComponent, - loader: ({ params, context }) => context.queryClient.fetchQuery({ - ...getRomsApiRomsGetOptions({ query: { collection_id: Number(params.id) } }), - staleTime: DefaultRommStaleTime, - }) -}); - -function RouteComponent () -{ - const { id } = Route.useParams(); - const { data: collection } = useQuery(getCollectionQuery(Number(id))); - const animatedBgContext = useContext(AnimatedBackgroundContext); - - return ( - {collection?.name}
    } filters={{ collection_id: Number(id) }} /> - ); -} diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx new file mode 100644 index 0000000..2f62d91 --- /dev/null +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { CollectionsDetail } from '../components/CollectionsDetail'; +import { useQuery } from '@tanstack/react-query'; +import { useContext } from 'react'; +import { AnimatedBackgroundContext } from '../scripts/contexts'; +import { getCollectionQuery } from '@queries/romm'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; + +export const Route = createFileRoute('/collection/$source/$id')({ + component: RouteComponent, + validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) +}); + +function RouteComponent () +{ + const { source, id } = Route.useParams(); + const { countHint } = Route.useSearch(); + const { data: collection } = useQuery(getCollectionQuery(source, id)); + const animatedBgContext = useContext(AnimatedBackgroundContext); + + return ( + {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 1969410..6f97fbc 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -22,6 +22,7 @@ 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 { AutoFocus } from "@/mainview/components/AutoFocus"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -29,7 +30,6 @@ export const Route = createFileRoute("/game/$source/$id")({ context.queryClient.prefetchQuery(gameQuery(params.source, params.id)); }, component: RouteComponent, - pendingComponent: GameDetailsUIPending, errorComponent: Error, validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); @@ -71,81 +71,6 @@ function Error (data: ErrorComponentProps) ; } -function MainDetailsPending () -{ - - const { ref } = useFocusable({ focusKey: "main-details" }); - - return
    -
    -
    -
    -
    -
    -
    - } > - } >
    - - } > - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    ; -} - -function GameDetailsUIPending () -{ - const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); - - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => - { - focusSelf(); - }, []); - - return -
    - -
    -
    - -
    -
    - -
    -
    -
    Screenshots
    -
    -
    - {Array.from({ length: 5 }).map((s, i) =>
    )} -
    -
    -
    - -
    -
    - -
    - ; -} - function MoreDetails (data: { game: FrontEndGameTypeDetailed | undefined; }) { const [details] = useDetailsSection(); @@ -219,7 +144,7 @@ function RouteComponent () 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" }); + 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; @@ -228,20 +153,8 @@ function RouteComponent () useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (focus) - { - setFocus(focus, { instant: true }); - } else - { - focusSelf(); - } - - }, []); - useStickyDataAttr(headerRef, sentinelRef, ref); - const recommendedEmulators = data?.emulators?.filter(e => e.validSource); + const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); const { ref: intersct } = useIntersectionObserver({ onChange: (isIntersecting, entry) => @@ -252,6 +165,7 @@ function RouteComponent () return ( + setUpdate(v => v + 1) }} > diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index b9ae196..d1071fa 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -12,10 +12,5 @@ function RouteComponent () { const { focus } = Route.useSearch(); - return ( -
    - -
    - ); + return ; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 6ac40a5..945b6d7 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -15,7 +15,7 @@ import { createFileRoute, } from "@tanstack/react-router"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { FocusContext, @@ -44,6 +44,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; +import { gameQuery } from "../scripts/queries/romm"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -101,6 +102,7 @@ function HomeList (data: { selectedFilter: string; }) { + const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); const { } = Route.useSearch; @@ -125,28 +127,20 @@ function HomeList (data: { Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const handleCollectionSelect = (id: string) => - { - Router.navigate({ to: `/collection/${id}` }); - }; - - const handlePlatformSelect = (source: string, id: string) => - { - Router.navigate({ to: `/platform/${source}/${id}` }); - }; - let activeList: JSX.Element; switch (data.selectedFilter) { case 'consoles': activeList = <> - - + }> + + + ; break; case 'collections': activeList = <> - + ; break; @@ -155,12 +149,17 @@ function HomeList (data: { + { + const [source, id] = l.split('@'); + queryClient.prefetchQuery(gameQuery(source, id)); + handleNodeFocus(l, n, d); + }} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} - filters={{ limit: 12 }} + filters={{ limit: 12, orderBy: 'activity' }} finalElement={} /> @@ -201,7 +200,7 @@ function HomeList (data: { }}>
    }> - }> + }> {activeList} @@ -223,6 +222,7 @@ function MainMenu () ref={ref} save-child-focus="session" className="flex items-center gap-y-1 sm:portrait:bg-base-100 sm:portrait:p-2 sm:portrait:rounded-full sm:gap-1 md:gap-3" + style={{ viewTransitionName: "main-menu" }} >
    - {!!data.pathCover && } - {data.platformName} + {!!platform && } + {platform?.name}
    ; } @@ -22,14 +28,15 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string; function RouteComponent () { const { source, id } = Route.useParams(); - const { data: platform } = useQuery(platformQuery(source, id)); + const { countHint } = Route.useSearch(); return (
    - {!!platform && } - filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }} - />} + } + filters={{ platform_id: Number(id), platform_source: source }} + />
    ); } diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 65cf0a0..7c0faf5 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, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; +import { Key, Link, Lock, LogIn, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; import { useEffect, @@ -24,7 +24,8 @@ import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; -import { rommGetOptionsQuery, rommHasPasswordQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { systemApi } from "@/mainview/scripts/clientApi"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, @@ -47,7 +48,10 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: {!!data.code &&

    Code: {data.code}

    } - +
    + + +
    ; } @@ -83,11 +87,12 @@ function TwitchLogin ()
    ; } -function LoginControls (data: { hasPassword: boolean; }) +function LoginControls (data: {}) { - const user = useQuery(rommUserQuery()); + const user = useQuery(rommUserQuery); const loginMutation = useMutation(rommQrLoginMutation); const { data: statusValue, wsRef } = useJobStatus('login-job'); + const { data: loginStatusData } = useQuery(rommLoggedInQuery); const context = useSettingsFormContext({}); const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ @@ -107,15 +112,15 @@ function LoginControls (data: { hasPassword: boolean; }) } - {data.hasPassword && + {loginStatusData?.hasLogin && }