From 06b7e4074da23afdec3b2ff97f84a9e1486944d2 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 4 May 2026 14:59:43 +0300 Subject: [PATCH] feat: Implemented local game import (with a wizard) feat: Implemented a radial virtual gamepad keyboard. fix: Fixed shortcuts for file explorer --- README.md | 4 +- bun.lock | 10 +- package.json | 2 +- src/bun/api/games/games.ts | 34 +- src/bun/api/games/platforms.ts | 49 +- src/bun/api/games/services/statusService.ts | 66 ++- src/bun/api/games/services/utils.ts | 305 ++++++++++- src/bun/api/hooks/games.ts | 9 +- src/bun/api/jobs/import-job.ts | 96 ++++ src/bun/api/jobs/install-job.ts | 184 ++----- .../com.simeonradivoev.gameflow.es/es-de.ts | 4 +- .../package.json | 2 +- .../com.simeonradivoev.gameflow.igdb/igdb.ts | 48 +- .../package.json | 2 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 2 + .../store.ts | 5 +- src/bun/api/schema/app.ts | 10 +- src/bun/api/settings/services.ts | 20 +- src/bun/api/system.ts | 8 +- src/bun/api/task-queue.ts | 39 +- src/mainview/assets/sounds.json | 38 +- src/mainview/assets/sounds.ogg | 4 +- src/mainview/components/AppCommunication.tsx | 2 + src/mainview/components/CollectionList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 15 +- src/mainview/components/ContextDialog.tsx | 4 +- src/mainview/components/FilePicker.tsx | 7 +- src/mainview/components/GamepadKeyboard.tsx | 509 ++++++++++++++++++ src/mainview/components/HeaderSearchField.tsx | 14 +- src/mainview/components/SelectMenu.tsx | 5 +- src/mainview/components/ShortcutPrompt.tsx | 7 +- src/mainview/components/Shortcuts.tsx | 48 +- src/mainview/components/SvgIcon.tsx | 5 +- .../components/game/ActionButtons.tsx | 33 +- src/mainview/components/game/GameLookup.tsx | 80 +++ src/mainview/components/game/MainActions.tsx | 84 ++- .../options/DownloadDirectoryOption.tsx | 9 +- .../components/options/LocalOption.tsx | 30 +- .../components/options/OptionInput.tsx | 3 - .../components/options/OptionSpace.tsx | 3 +- .../components/options/PathSettingsOption.tsx | 22 +- .../components/options/SettingsAppForm.tsx | 9 +- .../components/options/SettingsOption.tsx | 6 +- .../store/MissingEmulatorsSection.tsx | 17 +- src/mainview/gen/routeTree.gen.ts | 42 ++ src/mainview/routes/game/$source.$id.tsx | 3 + src/mainview/routes/game/add.tsx | 396 ++++++++++++++ .../routes/game/update.$source.$id.tsx | 61 +++ src/mainview/routes/games.tsx | 16 +- src/mainview/routes/platform.$source.$id.tsx | 14 +- src/mainview/routes/settings/emulators.tsx | 23 +- src/mainview/routes/settings/interface.tsx | 15 +- src/mainview/routes/store/tab/index.tsx | 2 +- src/mainview/routes/store/tab/route.tsx | 8 +- src/mainview/scripts/audio/audio.ts | 12 +- src/mainview/scripts/audio/audioConstants.ts | 17 +- src/mainview/scripts/brandIcons.tsx | 4 + src/mainview/scripts/gamepads.ts | 5 + src/mainview/scripts/queries/romm.ts | 73 ++- src/mainview/scripts/types.ts | 1 + src/mainview/scripts/utils.ts | 2 +- src/shared/constants.ts | 31 +- src/shared/types..d.ts | 40 ++ src/sounds/UI_Single_Set 5_01.wav | 3 + src/sounds/UI_Single_Set 5_03.wav | 3 + src/sounds/UI_Single_Set 5_04.wav | 3 + 66 files changed, 2216 insertions(+), 416 deletions(-) create mode 100644 src/bun/api/jobs/import-job.ts create mode 100644 src/mainview/components/GamepadKeyboard.tsx create mode 100644 src/mainview/components/game/GameLookup.tsx create mode 100644 src/mainview/routes/game/add.tsx create mode 100644 src/mainview/routes/game/update.$source.$id.tsx create mode 100644 src/sounds/UI_Single_Set 5_01.wav create mode 100644 src/sounds/UI_Single_Set 5_03.wav create mode 100644 src/sounds/UI_Single_Set 5_04.wav diff --git a/README.md b/README.md index a21e662..ceed36a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Focused on building a simple user experience and intuitive UI as a curated commu ### Integrations - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. + - Show Achievements and sync playtime. + - Experimental save syncing - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. - **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it. - **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) @@ -39,7 +41,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu ## Screenshots - + diff --git a/bun.lock b/bun.lock index 884980a..217584d 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", @@ -429,7 +429,11 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@2.3.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-gR//N45NnKz1h0/AVknkfg7QnNATETdgXUUD3EKPxuQPyhk7NhsphODzRamyvjYaxsU6VbY/szcUlzBWWBkNMw=="], + "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "@noriginmedia/norigin-spatial-navigation-react": "^3.1.0" } }, "sha512-KPge4ocpDFde7cpZ2aqrPrKmxOxkue983NsfpmE/vX4k2l+Ik8UkucCWGqkcy81TXkEyRhdsYwFTRePNB5qUCg=="], + + "@noriginmedia/norigin-spatial-navigation-core": ["@noriginmedia/norigin-spatial-navigation-core@3.1.0", "", { "dependencies": { "lodash-es": "^4.17.21" } }, "sha512-AFxJHurTqy+I3NLnaXsLUBa9FZjUryMNFEdLpPrITSqDjk525aINeLMOK1PN7WTiK5xpHL0pbpw0+uVOfWgp4w=="], + + "@noriginmedia/norigin-spatial-navigation-react": ["@noriginmedia/norigin-spatial-navigation-react@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "lodash-es": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-F2PIqzTnlYbbc+oRdIQfBf7e1VcA1uhyjze4uOal8FHI8tZs1U8nomH84+2KcM6G3EM/XGexgQsPy5f5dtrmUA=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], @@ -1259,6 +1263,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="], diff --git a/package.json b/package.json index 4925d6c..8abf0dd 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-form": "^1.28.0", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index bf64aaf..604932c 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; +import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; @@ -21,6 +21,7 @@ import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; +import { ImportJob } from "../jobs/import-job"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -491,6 +492,24 @@ export default new Elysia() { return update(source, id); }) + .post('/game/:source/:id/update', async ({ params: { id, source }, body }) => + { + return customUpdate(source, id, body.source, body.id); + }, { body: z.object({ source: z.string(), id: z.string() }) }) + .get('/lookup', async ({ query: { search } }) => + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ search, matches }); + return matches; + }, { + query: z.object({ search: z.string() }) + }) + .get('/lookup/:source/:id', async ({ params: { source, id } }) => + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source, id, matches }); + return matches; + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); @@ -651,4 +670,17 @@ export default new Elysia() rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank); return rankedGames.map(g => g.game).slice(0, 10); + }) + .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => + { + if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); + const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true); + return { source: 'local', id: data.localId }; + }, { + body: z.object({ + source: z.string(), + id: z.string(), + gamePath: z.string(), + platformId: z.number() + }) }); \ No newline at end of file diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index 22161ae..d1509a0 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,8 +1,9 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; -import { db, plugins } from "../app"; +import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm"; +import { config, db, plugins } from "../app"; import * as schema from "@schema/app"; +import { findPlatform } from "./services/utils"; export default new Elysia() .get('/platforms', async () => @@ -91,7 +92,8 @@ export default new Elysia() { const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); if (!remotePlatform) return status("Not Found"); - return remotePlatform; + const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) }); + return { ...remotePlatform, hasLocal: !!local }; } }, { params: z.object({ source: z.string(), id: z.string() }) }) .get('/platform/local/:id/cover', async ({ params: { id }, set }) => @@ -114,15 +116,31 @@ export default new Elysia() } return status(200, coverBlob.cover); }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) - .post('/platform/local/:id/update', async ({ params: { id } }) => + .post('/platform/:source/:id/update', async ({ params: { source, id } }) => { - const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); + const where: any[] = []; + if (source === 'local') + { + where.push(eq(schema.platforms.id, Number(id))); + } else + { + const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); + if (remotePlatform) + { + where.push(eq(schema.platforms.slug, remotePlatform.slug)); + } + } + + const localPlatform = await db.query.platforms.findFirst({ + where: or(...where) + + }); if (!localPlatform) return status("Not Found"); const platformLookup = await plugins.hooks.games.platformLookup.promise({ slug: localPlatform.slug }); - let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`); if (!platformCover.ok && platformLookup?.url_logo) { platformCover = await fetch(platformLookup.url_logo); @@ -144,4 +162,23 @@ export default new Elysia() .where(eq(schema.games.platform_id, Number(id))) ))).returning(); if (deleted.length <= 0) return status("Not Found"); + }) + .get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) => + { + const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id }); + if (!platformLookup) return status("Not Found"); + const match = await findPlatform({ + system_slug: platformLookup.slug, + platform: { + source_slug: platformLookup.slug, + source_id: Number(id), + source: source, + name: platformLookup.name + } + }); + return { details: platformLookup, match }; + }, { + detail: { + description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found." + } }); \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 3c46f23..b3a1691 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -4,7 +4,7 @@ import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; import Elysia from "elysia"; -import z, { string } from "zod"; +import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; @@ -41,6 +41,63 @@ export async function getLocalGame (source: string, id: string) return localGame; } +/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */ +export async function customUpdate (source: string, id: string, destination: string, destinationId: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: destination, id: destinationId, matches }); + if (matches.length <= 0) throw new Error("Could not find destination"); + const match = matches[0]; + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + // pre-fetch screenshots + const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + let cover: Buffer | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + } + } + + await tx.update(appSchema.games).set({ + cover, + metadata: { + age_ratings: match.age_ratings, + genres: match.genres, + player_count: match.player_count ?? undefined, + companies: match.companies, + game_modes: match.game_modes, + average_rating: match.average_rating ?? undefined, + first_release_date: match.first_release_date, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + export async function update (source: string, id: string) { const localGame = await getLocalGame(source, id); @@ -56,10 +113,11 @@ export async function update (source: string, id: string) const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; if (paths_screenshots.length <= 0 && sourceGame.igdb_id) { - const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); - if (igdbLookup) + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches }); + if (matches.length > 0) { - paths_screenshots.push(...igdbLookup.screenshotUrls); + paths_screenshots.push(...matches[0].screenshotUrls); } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index b1b6fc2..833fbdd 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -2,23 +2,25 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import * as schema from "@schema/app"; -import { RPC_URL, StoreGameType } from "@shared/constants"; +import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; -import secrets from "../../secrets"; +import * as emulatorSchema from "@schema/emulators"; export async function calculateSize (installPath: string | null) { if (!installPath) return null; - return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size; + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return (await getFolderSize(finalPath)).size; } export async function checkInstalled (installPath: string | null) { if (!installPath) return false; - return fs.exists(path.join(config.get('downloadPath'), installPath)); + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return fs.exists(finalPath); } export function getScreenshotLocalGameMatch (id: string, source: string) @@ -171,4 +173,297 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean } return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; })); +} + +export async function findPlatform (info: { + system_slug: string; platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}): + Promise<{ + type: string | null; + slug?: string | null; + name?: string | null; + family_name?: string | null; + es_slug?: string | null; + coverUrl?: string | null; + }> +{ + // Search for existing platform + const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; + const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; + + if (info.platform) + { + 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, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) }); + + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" }); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" }); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } else + { + return { type: "unknown" }; + } + } else + { + return { + type: "new", + slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '', + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + coverUrl: platformCover.url + }; + } + + } else + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } +} + +export async function createLocalGame (info: { + name: string; + system_slug: string | undefined; + source: string | undefined; + source_id: string | undefined; + slug: string | null | undefined; + path_fs: string | null | undefined; + summary: string | null | undefined; + igdb_id: number | undefined; + ra_id: number | undefined; + main_glob: string | undefined; + cover: Buffer | undefined; + coverType: string | null | undefined; + version: string | undefined; + version_source: string | undefined; + screenshotUrls: string[]; + version_system: string | undefined; + last_played?: Date; + metadata: LocalGameMetadata | undefined, + platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}) +{ + const id = await db.transaction(async (tx) => + { + // Search for existing platform + const platformSearch = []; + const esPlatformSearch = []; + if (info.system_slug) + { + platformSearch.push(eq(schema.platforms.slug, info.system_slug)); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug)); + } + + if (info.platform) + { + 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, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); + let platformId: number; + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + platformId = existingPlatform.id; + } else + { + const [{ id }] = await tx.insert(schema.platforms).values({ + slug: 'unknown', + name: "Unknown" + }).returning({ id: schema.platforms.id }); + platformId = id; + } + } else + { + // Create new local platform + const platform: typeof schema.platforms.$inferInsert = { + slug: info.platform?.source_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: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + }; + + // TODO: add ES slug once I have better way to query ES + const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); + platformId = id; + } + + } else + { + platformId = existingPlatform.id; + } + + // create the rom + const game: typeof schema.games.$inferInsert = { + source_id: info.source_id, + source: info.source, + slug: info.slug, + path_fs: info.path_fs, + last_played: info.last_played, + platform_id: platformId, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + summary: info.summary, + name: info.name, + cover: info.cover, + cover_type: info.coverType, + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system + }; + + const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); + + if (info.screenshotUrls.length <= 0 && info.igdb_id) + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id), matches }); + info.screenshotUrls.push(...matches[0].screenshotUrls); + } + + // pre-fetch screenshots + const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof schema.screenshots.$inferInsert = { + game_id: id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + return id; + }); + + return id; } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index fb94e71..b08607c 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -1,5 +1,5 @@ import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook } from 'tapable'; export class GameHooks { @@ -95,7 +95,12 @@ export class GameHooks name?: string; family_name?: string; } | undefined>(['ctx']); - gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']); + gameLookup = new AsyncSeriesHook<[ctx: { + source?: string, + id?: string; + search?: string; + matches: GameLookup[]; + }]>(['ctx']); fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); diff --git a/src/bun/api/jobs/import-job.ts b/src/bun/api/jobs/import-job.ts new file mode 100644 index 0000000..91e118b --- /dev/null +++ b/src/bun/api/jobs/import-job.ts @@ -0,0 +1,96 @@ +import { eq, or } from "drizzle-orm"; +import { db, plugins } from "../app"; +import { createLocalGame } from "../games/services/utils"; +import { IJob, JobContext } from "../task-queue"; +import * as schema from "@schema/app"; +import z from "zod"; + +export class ImportJob implements IJob, string> +{ + static id = "import-job" as const; + static dataSchema = z.object({ localId: z.number().nullable() }); + group?: 'import-job'; + gamePath: string; + source: string; + id: string; + platformId: number; + localId: number | null = null; + + constructor(source: string, id: string, gamePath: string, platformId: number) + { + this.gamePath = gamePath; + this.source = source; + this.id = id; + this.platformId = platformId; + } + + exposeData (): z.infer + { + return { localId: this.localId }; + } + + async start (context: JobContext, string>, z.infer, string>): Promise + { + const matches: GameLookup[] = []; + await plugins.hooks.games.gameLookup.promise({ source: this.source, id: this.id, matches }); + if (matches.length <= 0) throw Error("Could not Find Game"); + const match = matches[0]; + + let cover: Buffer | undefined = undefined; + let coverType: string | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + coverType = coverResponse.headers.get('content-type') ?? undefined; + } + } + + const localSearchFilters: any[] = []; + if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id)); + if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug)); + localSearchFilters.push(eq(schema.games.name, match.name)); + localSearchFilters.push(eq(schema.games.path_fs, this.gamePath)); + const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) }); + + if (existingLocalGame) throw new Error("Game Already Exists"); + + const platformMatch = match.platforms.find(p => p.id === this.platformId); + + this.localId = await createLocalGame({ + name: match.name, + system_slug: platformMatch?.slug, + source: undefined, + source_id: undefined, + slug: match.slug, + path_fs: this.gamePath, + summary: match.summary, + igdb_id: match.igdb_id, + ra_id: undefined, + main_glob: undefined, + cover, + coverType, + version: undefined, + version_source: undefined, + screenshotUrls: match.screenshotUrls, + version_system: undefined, + platform: platformMatch ? { + source_slug: platformMatch.slug, + source_id: platformMatch.id, + source: this.source, + name: platformMatch.displayName + } : undefined, + metadata: { + game_modes: match.game_modes, + companies: match.companies, + first_release_date: match.first_release_date ?? undefined, + player_count: match.player_count, + age_ratings: match.age_ratings, + average_rating: match.average_rating, + genres: match.genres, + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 9a2b8b8..1b48394 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,17 +1,12 @@ import { IJob, JobContext } from "../task-queue"; -import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; -import * as schema from "@schema/app"; -import * as emulatorSchema from "@schema/emulators"; -import path, { join } from 'node:path'; -import { config, db, emulatorsDb, events, plugins } from "../app"; -import * as igdb from 'ts-igdb-client'; -import secrets from "../secrets"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; -import { checkFiles } from "../games/services/utils"; +import { checkFiles, createLocalGame } from "../games/services/utils"; import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; import StreamZip from 'node-stream-zip'; @@ -37,6 +32,7 @@ export class InstallJob implements IJob // The local game ID of newly created entry, if successful public localGameId?: number; public group = InstallJob.id; + public localPath?: string; constructor(id: string, source: string, config?: JobConfig) { @@ -51,18 +47,19 @@ export class InstallJob implements IJob await fs.mkdir(config.get('downloadPath'), { recursive: true }); const downloadPath = config.get('downloadPath'); - let info: DownloadInfo | undefined; - - const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); - info = allDownloads?.[0]; - - if (!info) throw new Error(`Could not find downloader for source ${this.source}`); - - const files = await checkFiles(info.files, !!info.extract_path); const finalFiles: string[] = []; + let info: DownloadInfo | undefined; if (this.config?.dryRun !== true) { + const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); + info = allDownloads?.[0]; + + 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?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) { const headers: Record = {}; @@ -197,143 +194,32 @@ export class InstallJob implements IJob if (cx.abortSignal.aborted) return; - await db.transaction(async (tx) => - { - // Search for existing platform - const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; - const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; - - if (info.platform) - { - 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, info.platform.slug)); - } - - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ - with: { system: true }, - where: and(...esPlatformSearch) - }); - - if (esPlatform) - platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); - - let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); - let platformId: number; - if (!existingPlatform) - { - // TODO: use something else than the romm demo as CDN - - const platformLookup = await plugins.hooks.games.platformLookup.promise({ - slug: info.platform?.slug ?? info.system_slug - }); - let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`); - if (!platformCover.ok && platformLookup?.url_logo) - { - platformCover = await fetch(platformLookup.url_logo); - } - - if (!esPlatform && !info.platform) - { - // go to unknown platform - existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); - - if (existingPlatform) - { - platformId = existingPlatform.id; - } else - { - const [{ id }] = await tx.insert(schema.platforms).values({ - slug: 'unknown', - name: "Unknown" - }).returning({ id: schema.platforms.id }); - platformId = id; - } - } else - { - // Create new local platform - const platform: typeof schema.platforms.$inferInsert = { - 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: info.platform?.name ?? esPlatform?.system.fullname ?? '', - family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined, - }; - - // TODO: add ES slug once I have better way to query ES - const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); - platformId = id; - } - - } else - { - platformId = existingPlatform.id; - } - - // create the rom - const game: typeof schema.games.$inferInsert = { - source_id: info.source_id, - source: this.source, - slug: info.slug, - path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), - last_played: info.last_played, - platform_id: platformId, - igdb_id: info.igdb_id, - ra_id: info.ra_id, - summary: info.summary, - name: info.name, - cover, - cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata, - main_glob: info.main_glob, - version: info.version, - version_source: info.version_source, - version_system: info.version_system - }; - - const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - - if (info.screenshotUrls.length <= 0 && info.igdb_id) - { - const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) }); - if (igdbLookup) return igdbLookup.screenshotUrls; - return []; - } - - // pre-fetch screenshots - const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); - - if (screenshots.length > 0) - { - await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => - { - const screenshot: typeof schema.screenshots.$inferInsert = { - game_id: id, - content: Buffer.from(await response.arrayBuffer()), - type: response.headers.get('content-type') - }; - - return screenshot; - }))); - } - - this.localGameId = id; + this.localGameId = await createLocalGame({ + cover, + coverType: coverResponse.headers.get('content-type'), + system_slug: info.system_slug, + source_id: info.source_id, + source: this.source, + slug: info.slug, + path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), + summary: info.summary, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + name: info.name, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + screenshotUrls: info.screenshotUrls, + version_system: info.version_system, + metadata: info.metadata, + platform: info.platform }); + + if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); + events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } else { await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } - - await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); - - events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts index 5f10e24..7108dcf 100644 --- a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -288,7 +288,7 @@ export default class IgdbIntegration implements PluginType } const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); + const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath); const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); @@ -449,7 +449,7 @@ export default class IgdbIntegration implements PluginType } const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); + const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs); return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); }); diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json index 20a8525..2c42339 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "Rclone integration for syncing saves", "main": "./rclone.ts", - "icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E", "category": "saves", "keywords": [ "integration", diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index ba7bfed..71fa313 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -42,17 +42,53 @@ export default class IgdbIntegration implements PluginType { await checkLoginAndRefreshTwitch(); - ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id, search, matches }) => { if (!process.env.TWITCH_CLIENT_ID) return; - if (source !== 'igdb') return; - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) + if (!access_token) + { + return; + } + + if ((source === 'igdb' && id) || search) { const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute(); - return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) }; + + const { data: games } = await this.queue.add(() => client.request('games') + .pipe(...(search ? [igdb.search(search)] : []), + igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']), + ...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []), + igdb.limit(10)).execute()); + + matches.push(...games.filter(g => !!g.name) + .map(g => + { + const lookup: GameLookup = { + source: 'igdb', + id: String(g.id), + coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined, + screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [], + name: g.name!, + summary: g.summary, + genres: g.genres?.map(g => g.name!) ?? [], + companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [], + game_modes: g.game_modes?.map(m => m.name!) ?? [], + age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [], + player_count: undefined, + // UNIX date, needs to be converted + first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined, + average_rating: g.rating ?? undefined, + keywords: g.keywords?.map(k => k.name!) ?? [], + igdb_id: g.id, + platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [], + slug: g.slug + }; + + return lookup; + })); + + return; } }); diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json index b1cd2e8..55939bb 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "IGDB Metadata Integration", "main": "./igdb.ts", - "icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E", "category": "sources", "keywords": [ "integration", diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index e049285..0515ef0 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -253,6 +253,8 @@ export default class RommIntegration implements PluginType const info: DownloadInfo = { platform: { + source: 'romm', + id: String(rommPlatform.id), slug: rommPlatform.slug, name: rommPlatform.name, family_name: rommPlatform.family_name ?? undefined diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index c57330e..2e0d507 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,6 +1,6 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import path, { basename, dirname } from 'node:path'; +import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { Glob, pathToFileURL } from "bun"; import { and, eq } from "drizzle-orm"; @@ -12,7 +12,6 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; -import { path7za } from "7zip-bin"; export default class RommIntegration implements PluginType { @@ -314,6 +313,8 @@ export default class RommIntegration implements PluginType version_system: validDownload.system, version_source: validDownload.id, platform: { + source: 'store', + id: system, slug: system, name: system } diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index 2226b20..6008689 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -12,15 +12,7 @@ export const games = sqliteTable('games', { main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ - genres?: string[], - companies?: string[], - game_modes?: string[], - age_ratings?: string[]; - player_count?: string; - first_release_date?: number; - average_rating?: number; - }>().notNull(), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index ea76797..f5b2d71 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -57,15 +57,6 @@ export async function getRelevantEmulators () await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); const integrations = findEmulatorPluginIntegration(emulator, execPaths); - const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); - - if (storeEmulator) - { - storeEmulator.validSources = execPaths; - storeEmulator.integrations = integrations; - return storeEmulator; - } - let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); if (validSystemSlug?.system) @@ -78,7 +69,17 @@ export async function getRelevantEmulators () systems.forEach(s => platformViability.set(s, true)); } + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return { ...storeEmulator, isCritical: false }; + } + const em: FrontEndEmulator & { isCritical: boolean; } = { + source: 'local', name: emulator, logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', 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 ?? '' })), @@ -92,6 +93,7 @@ export async function getRelevantEmulators () })); finalEmulators.push({ + source: 'local', name: 'EMULATORJS', validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index a706563..504cc25 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cache, cachePath, config, events, taskQueue } from "./app"; +import { cachePath, config, events, taskQueue } from "./app"; import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -14,7 +14,7 @@ import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; -import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache"; +import { getOrCachedGithubRelease } from "./cache"; import SelfUpdateJob from "./jobs/self-update-job"; async function checkUpdate (force?: boolean) @@ -239,6 +239,10 @@ export const system = new Elysia({ prefix: '/api/system' }) { currentPath = path.resolve(process.cwd(), currentPath); } + const currentPathExists = await fs.exists(currentPath); + if (!currentPathExists) currentPath = dirname(process.cwd()); + const currentPathStat = await fs.stat(currentPath); + if (!currentPathStat.isDirectory()) currentPath = dirname(currentPath); const paths = await fs.readdir(currentPath, { withFileTypes: true }); return { name: path.basename(currentPath), diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index bb890df..a54026a 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,8 +1,5 @@ - - -import { and } from 'drizzle-orm'; import EventEmitter from 'node:events'; -import z, { any } from 'zod'; +import z from 'zod'; export class TaskQueue { @@ -10,7 +7,16 @@ export class TaskQueue private queue?: JobContext, any, string>[] = []; private events?: EventEmitter = new EventEmitter(); - public enqueue (id: string, job: T): T extends IJob + constructor() + { + // we need a default error listener or app crashes + this.events?.addListener('error', e => + { + console.error(e); + }); + } + + public enqueue (id: string, job: T, throwOnError?: boolean): T extends IJob ? Promise : never { @@ -35,7 +41,7 @@ export class TaskQueue { job.job.start(); this.activeQueue.push(job.job); - job.job.promise.promise.finally(() => + job.job.promise.promise.catch(e => { }).finally(() => { const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); @@ -235,26 +241,21 @@ export class JobContext, TData, TState extends str } } catch (error) { - try + if (error instanceof Event) { - if (error instanceof Event) + if (error.target instanceof AbortSignal) { - if (error.target instanceof AbortSignal) - { - - } else - { - console.error(error); - } + this.m_promise.resolve(undefined); } else { console.error(error); - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; + this.m_promise.reject(error); } - } finally + } else { - this.m_promise.resolve(undefined); + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + this.m_promise.reject(error); } } finally diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json index 97b63f9..309705d 100644 --- a/src/mainview/assets/sounds.json +++ b/src/mainview/assets/sounds.json @@ -36,28 +36,52 @@ 34000, 2489.5918367346967 ], - "Classic UI SFX - Chords #16": [ + "Classic UI SFX - Short - High #25": [ 38000, + 2005.215419501134 + ], + "Classic UI SFX - Chords #16": [ + 42000, 4005.215419501134 ], "Classic UI SFX - Short - High #8": [ - 44000, + 48000, 2916.6666666666642 ], "UI_Single_Set 16_03": [ - 48000, + 52000, 309.5918367346968 ], "UI_Single_Set 16_01": [ - 50000, + 54000, 309.5918367346968 ], + "UI_Single_Set 5_02": [ + 56000, + 875.0113378684787 + ], + "UI_Single_Set 5_04": [ + 58000, + 531.247165532882 + ], + "UI_Single_Set 5_03": [ + 60000, + 531.247165532882 + ], + "UI_Single_Set 5_01": [ + 62000, + 875.0113378684787 + ], + "UI_Single_Set 11_02": [ + 64000, + 93.74149659863917 + ], "Classic UI SFX - Short - Low #6": [ - 52000, - 2333.3333333333358 + 66000, + 2333.3333333333285 ], "UI SFX_InGameMenu_Open": [ - 56000, + 70000, 2614.104308390026 ] } diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg index 0b7ac9b..835432f 100644 --- a/src/mainview/assets/sounds.ogg +++ b/src/mainview/assets/sounds.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5dd2b1e23a878efe84694fa354e92e07f9394d88217b0f1d925f3b16f044e55 -size 353897 +oid sha256:38721ebc90eb07ef7e00c0a1a64bd363e61dbbd08aa32b12a33da5ead0597948 +size 408079 diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index 98f8c65..bbb26c3 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -3,6 +3,7 @@ import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; import { SystemInfoType } from "@/shared/constants"; import LoadingScreen from "./LoadingScreen"; +import { GamepadKeyboard } from "./GamepadKeyboard"; export default function AppCommunication (data: { children: any; }) { @@ -55,5 +56,6 @@ export default function AppCommunication (data: { children: any; }) : data.children} + ; } \ No newline at end of file diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index c04db7f..ac30c57 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -8,10 +8,9 @@ export default function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; onSelect?: (id: string) => void; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const router = useRouter(); const { data: collections } = useSuspenseQuery(getCollectionsQuery); @@ -37,7 +36,7 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, + previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ {g.game_count} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 8108149..9d6632c 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,24 +1,17 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react'; -import { JSX, Suspense, useRef, useState } from 'react'; +import { JSX, Suspense } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; -import { useNavigate, useRouter } from '@tanstack/react-router'; +import { useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; -import { RoundButton } from './RoundButton'; -import { ContextList, DialogEntry, useContextDialog } from './ContextDialog'; -import classNames from 'classnames'; -import { sourceIconMap } from './Constants'; -import { stat } from 'fs-extra'; -import { FilterUI } from './Filters'; import SideFilters from './SideFilters'; export interface CollectionsDetailParams @@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
- {finalFilter && data.title} + {!!finalFilter && data.title} {}> - {data.options?.map((o, i) => )} + {data.options?.map((o, i) => )} {data.showCloseButton !== false &&
} {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />} ; @@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class }; const { ref, focusSelf, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), - onEnterPress: data.shortcuts ? undefined : handleAction, + onEnterPress: handleAction, onFocus: handleFocus, trackChildren: typeof data.content !== 'string' }); diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 6788952..09bc8f5 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,9 +1,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; -import { systemApi } from "../scripts/clientApi"; import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; -import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; +import { Check, File, FileInput, 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"; @@ -15,7 +14,6 @@ import toast from "react-hot-toast"; import { FilePickerContext } from "../scripts/contexts"; import useActiveControl from "../scripts/gamepads"; import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system"; -import { showKeyboardHandler } from "../scripts/utils"; function List (data: { id: string, @@ -48,7 +46,7 @@ function List (data: { let icon = ; if (isDefaultPath) { - icon = ; + icon = f.isDirectory ? : ; } else if (!f.isDirectory) { icon = ; @@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName: const handleFocus: FocusEventHandler = (e) => { focusSelf(); - showKeyboardHandler(control as any, e.target); }; return
= { + '⌫': { bg: "var(--color-accent)", color: "var(--color-accent-content)" }, + '⏎': { bg: "var(--color-secondary)", color: "var(--color-secondary-content)" }, + '␣': { bg: "var(--color-info)", color: "var(--color-info-content)" }, +}; +const Shortcuts: Record = { + '⌫': GamePadButtonCode.X, + '␣': GamePadButtonCode.Y, + '⏎': GamePadButtonCode.A, + '←': GamePadButtonCode.Left, + '→': GamePadButtonCode.Right, + '⇧': GamePadButtonCode.RJoy, + '⌥': GamePadButtonCode.LJoy +}; +const KeyElements: Record = { + '⌫': , + '␣': , + '⏎': , + '←': , + '→': , +}; +const DZ = 0.22, TH = 0.85, NS = 'http://www.w3.org/2000/svg'; + +function ang (x: number, y: number) +{ + if (Math.sqrt(x * x + y * y) < DZ) return null; + let a = Math.atan2(x, -y); + if (a < 0) a += Math.PI * 2; + return a; +} + +function gidx (a: number | null, n: number) +{ + return a === null ? -1 : Math.floor(a / (Math.PI * 2) * n) % n; +} + +function buildWheel (side: 0 | 1, shift: boolean, characters: boolean) +{ + const elements: JSX.Element[] = []; + const refs: RefObject[] = []; + const positions: { left: string; top: string; }[] = []; + const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028; + + for (let i = 0; i < n; i++) + { + const a0 = i / n * Math.PI * 2 - Math.PI / 2 + GAP; + const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - GAP; + const am = (a0 + a1) / 2; + const ref = createRef(); + const x = Math.cos(am); + const y = Math.sin(am); + refs.push(ref); + + const tr = 66; + positions.push({ left: `50% + ${tr * x}% - 16px`, top: `50% + ${tr * y}% - 16px` }); + + elements.push(<> + + {KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()} + + ); + } + + return { elements, refs, positions }; +} + +export type EditableInput = HTMLInputElement | HTMLTextAreaElement; + +export function typeKey (el: EditableInput, key: string): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + el.value = + el.value.slice(0, start) + + key + + el.value.slice(end); + + const pos = start + key.length; + el.setSelectionRange(pos, pos); +} + +export function backspace (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + // selection delete + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + // nothing to delete + if (start === 0) return; + + el.value = + el.value.slice(0, start - 1) + + el.value.slice(end); + + el.setSelectionRange(start - 1, start - 1); +} + +export function deleteForward (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + if (start >= el.value.length) return; + + el.value = + el.value.slice(0, start) + + el.value.slice(start + 1); + + el.setSelectionRange(start, start); +} + +export function enter (el: EditableInput): void +{ + if (el instanceof HTMLTextAreaElement) + { + + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + const insert = "\n"; + + el.value = + el.value.slice(0, start) + + insert + + el.value.slice(end); + + const pos = start + 1; + el.setSelectionRange(pos, pos); + + } else + { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + } + +} + +export function arrowLeft (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.max(0, pos - 1); + + el.setSelectionRange(newPos, newPos); +} + +export function arrowRight (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.min(el.value.length, pos + 1); + + el.setSelectionRange(newPos, newPos); +} + +export function GamepadKeyboard () +{ + const triggerThreshold = 0.85; + const [focusedInput, setFocusedInput] = useState(null); + const circleRefs = [useRef(null), useRef(null)]; + const sideRefs = [useRef(null), useRef(null)]; + const keyIndicatorRefs = [useRef(null), useRef(null)]; + const activeControl = useActiveControl(); + const hidden = !focusedInput || activeControl.control !== 'gamepad'; + const keyboardRef = useRef(null); + const [shift, setShift] = useState(false); + const [characters, setCharacters] = useState(false); + + useEffect(() => + { + if (!hidden) + { + oneShot('openKeyboard'); + } + }, [hidden]); + + const elements = [buildWheel(0, shift, characters), buildWheel(1, shift, characters)]; + + useEffect(() => + { + let disposed = false; + const lockedIds: [number | undefined, number | undefined] = [undefined, undefined]; + const actionRepeatTimeout: [NodeJS.Timeout | undefined, NodeJS.Timeout | undefined] = [undefined, undefined]; + const actionRepeatCount = [0, 0]; + const prevTriggerValues = [0, 0]; + const buttonValues: Record = {}; + const buttonRepeatTimeout: Record = {}; + const buttonRepeatCounts: Record = {}; + const lastIndexes = [-1, -1]; + + function update () + { + const gps = navigator.getGamepads ? navigator.getGamepads() : []; + const gp = [...gps].find(g => g); + + if (keyboardRef.current && focusedInput && !hidden) + { + const targetRect = focusedInput.getBoundingClientRect(); + const el = keyboardRef.current; + + // First, measure the element itself + const elRect = el.getBoundingClientRect(); + + const margin = 64; // keep some space from edges + + let left = targetRect.left; + let top = targetRect.bottom + 128; + + // Clamp horizontally + if (left + elRect.width > window.innerWidth - margin) + { + left = window.innerWidth - elRect.width - margin; + } + + if (left < margin) + { + left = margin; + } + + // Clamp vertically + if (top + elRect.height > window.innerHeight - margin) + { + // flip above the input if it doesn't fit below + top = targetRect.top - elRect.height - 128; + } + + if (top < margin) + { + top = margin; + } + + el.style.position = "fixed"; + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + + if (gp && !hidden) + { + function pressKey (el: EditableInput, key: string, repeatCount: number): void + { + const hapticIntensity = 1 / Math.max(repeatCount, 1); + const soundIntensity = 1 / Math.min(2, Math.max(repeatCount * 0.2, 1)); + gp?.vibrationActuator.playEffect('dual-rumble', { duration: 60, strongMagnitude: hapticIntensity, weakMagnitude: hapticIntensity }); + + switch (key) + { + case "⌫": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return backspace(el); + case "Delete": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return deleteForward(el); + case "←": + oneShot('keyPress', { volume: soundIntensity }); + return arrowLeft(el); + case "→": + oneShot('keyPress', { volume: soundIntensity }); + return arrowRight(el); + case "⏎": + oneShot('keyPress', { volume: soundIntensity }); + return enter(el); + case "␣": + oneShot('keyPressSpace', { volume: soundIntensity }); + return typeKey(el, ' '); + case "⇧": + setShift(v => !v); + return; + case "⌥": + setCharacters(v => !v); + return; + default: + oneShot('keyPress', { volume: soundIntensity }); + return typeKey(el, shift ? key.toUpperCase() : key.toLocaleLowerCase()); + } + } + + for (let side = 0; side < 2; side++) + { + const x = gp.axes[side * 2] ?? 0; + const y = gp.axes[side * 2 + 1] ?? 0; + const triggerValue = Math.max(gp.buttons[6 + side]?.value ?? 0, gp.buttons[4 + side]?.value ?? 0); + const angle = ang(x, y); + const keyIndex = lockedIds[side] !== undefined ? lockedIds[side]! : gidx(angle, GetKeys(characters)[side].length); + + elements[side].refs.filter(e => e.current).forEach((e, i) => + { + const active = keyIndex === i; + const key = GetKeys(characters)[side][i]; + const elem = e.current!; + elem.style.backgroundColor = active ? 'var(--color-primary)' : KeyColors[key]?.bg ?? ''; + elem.style.color = active ? 'var(--color-primary-content)' : KeyColors[key]?.color ?? ''; + elem.style.scale = `${active ? 150 : 100}%`; + elem.style.fontStyle = active ? 'bold' : 'normal'; + }); + + const circle = circleRefs[side].current!; + + // Update actions + if (keyIndex >= 0) + { + if (focusedInput) + { + if (triggerValue >= triggerThreshold && prevTriggerValues[side] < triggerThreshold) + { + const timeoutCalc = () => 400 / Math.min(4, Math.max(1, 1 + (actionRepeatCount[side] ?? 0))); + const handleRepeat = () => + { + elements[side].refs[keyIndex].current!.animate([ + { boxShadow: "0 0 0 0 var(--color-base-content)" }, + { boxShadow: "0 0 0 10px transparent" } + ], + { duration: 300, easing: 'ease-out', fill: 'none' } + ); + pressKey(focusedInput, GetKeys(characters)[side][keyIndex], actionRepeatCount[side]); + actionRepeatCount[side]++; + actionRepeatTimeout[side] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (triggerValue < triggerThreshold && prevTriggerValues[side] >= triggerThreshold) + { + clearTimeout(actionRepeatTimeout[side]); + actionRepeatCount[side] = -1; + } + + if (lockedIds[side] === undefined && triggerValue > 0.1) + { + lockedIds[side] = keyIndex; + } else if (lockedIds[side] !== undefined && triggerValue <= 0.1) + { + lockedIds[side] = undefined; + } + } + + keyIndicatorRefs[side].current!.textContent = shift ? GetKeys(characters)[side][keyIndex].toUpperCase() : GetKeys(characters)[side][keyIndex].toLowerCase(); + } else + { + keyIndicatorRefs[side].current!.textContent = ""; + } + + // Update cirlce + const magnitudeSqr = (x * x) + (y * y); + const magnitude = Math.sqrt(magnitudeSqr); + + const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex]; + //const lerpX = (element?.left ?? 0); + //const lerpY = (element?.top ?? 0); + const size = 12; + circle.style.left = `calc(50% + ${50 * x}% - 16px)`; + circle.style.top = `calc(50% + ${50 * y}% - 16px)`; + circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; + circle.style.backgroundColor = `color-mix(in srgb, var(--color-base-content), 'var(--color-primary)'} ${magnitude * 100}%)`; + + if (sideRefs[side].current) + { + sideRefs[side].current!.style.background = `radial-gradient( + circle at calc(50% + ${100 * x}px) calc(50% + ${100 * y}px), + color-mix(in srgb, var(--color-primary) 20%, transparent), + transparent + )`; + } + + + if (lastIndexes[side] !== keyIndex) + { + gp.vibrationActuator.playEffect('dual-rumble', { duration: 30, strongMagnitude: 0, weakMagnitude: 0.2 }); + oneShot('keyHover'); + } + + prevTriggerValues[side] = triggerValue; + lastIndexes[side] = keyIndex; + } + + const shortcutKeys = Object.entries(Shortcuts); + function handleButton (key: number, repeatCount: number) + { + if (!focusedInput) return; + const entry = shortcutKeys.find(([n, value]) => value === key); + if (key === GamePadButtonCode.A) return; + if (entry) + { + pressKey(focusedInput, entry[0], repeatCount); + } + } + + for (let i = 0; i < gp.buttons.length; i++) + { + const btn = gp.buttons[i]; + if (btn.value >= 0.85 && buttonValues[i] < 0.85) + { + const timeoutCalc = () => 400 / Math.min(8, Math.max(1, 1 + (buttonRepeatCounts[i] ?? 0))); + const handleRepeat = () => + { + handleButton(i, buttonRepeatCounts[i]); + buttonRepeatCounts[i] = (buttonRepeatCounts[i] ?? -1) + 1; + buttonRepeatTimeout[i] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (btn.value < 0.85 && buttonValues[i] >= 0.85) + { + clearTimeout(buttonRepeatTimeout[i]); + buttonRepeatCounts[i] = -1; + } + + buttonValues[i] = btn.value; + } + } + + if (!disposed && !hidden) requestAnimationFrame(update); + } + + if (!disposed && !hidden) requestAnimationFrame(update); + + return () => + { + disposed = true; + Object.values(buttonRepeatTimeout).forEach(v => clearTimeout(v)); + Object.values(actionRepeatTimeout).forEach(v => clearTimeout(v)); + }; + }, [focusedInput, elements, shift, characters, hidden]); + + useEffect(() => + { + + const handleFocus = (e: FocusEvent) => + { + if (e.target instanceof HTMLInputElement && (e.target.type === 'text' || e.target.type === 'search')) + { + if (!getLocalSetting('autoKeybaord')) return; + if (getLocalSetting('useGameflowKeyboard')) + { + setFocusedInput(e.target); + } else + { + showKeyboardHandler(activeControl.control, e.target); + } + } + }; + + const handleBlur = (e: FocusEvent) => + { + setFocusedInput(null); + }; + + document.addEventListener('focusin', handleFocus); + document.addEventListener('focusout', handleBlur); + + return () => + { + document.removeEventListener('focusin', handleFocus); + document.removeEventListener('focusout', handleBlur); + }; + }, []); + + return ; +} \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 50befd9..db83aad 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -1,13 +1,12 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; -import { systemApi } from "../scripts/clientApi"; -import { showKeyboardHandler } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; +import { twMerge } from "tailwind-merge"; function SearchInput (data: { id: string; @@ -16,6 +15,7 @@ function SearchInput (data: { compact: boolean | undefined; onInputFocus: () => void; setShowInput: (show: boolean) => void; + className?: string; onSubmit: (search: string | undefined) => void; } & FocusParams) { @@ -63,9 +63,7 @@ function SearchInput (data: { data.onSubmit?.(undefined); }, inputRef as any); - const handlInputFocus: FocusEventHandler = e => showKeyboardHandler(control as any, e.target); - - return
); diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index 03d5e03..a71eeca 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -1,36 +1,36 @@ -import { useContext } from 'react'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; -import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; -import { ShortcutsContext } from '../scripts/contexts'; export function FloatingShortcuts () { return
; } +export const GamepadIconMap: Record = { + [GamePadButtonCode.A]: 'steamdeck_button_a', + [GamePadButtonCode.B]: 'steamdeck_button_b', + [GamePadButtonCode.X]: 'steamdeck_button_x', + [GamePadButtonCode.Y]: 'steamdeck_button_y', + [GamePadButtonCode.L1]: 'steamdeck_button_l1', + [GamePadButtonCode.R1]: 'steamdeck_button_r1', + [GamePadButtonCode.L2]: 'steamdeck_button_l2', + [GamePadButtonCode.R2]: 'steamdeck_button_r2', + [GamePadButtonCode.Select]: 'steamdeck_button_guide', + [GamePadButtonCode.Start]: 'steamdeck_button_options', + [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', + [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', + [GamePadButtonCode.Up]: 'steamdeck_dpad_up', + [GamePadButtonCode.Down]: 'steamdeck_dpad_down', + [GamePadButtonCode.Left]: 'steamdeck_dpad_left', + [GamePadButtonCode.Right]: 'steamdeck_dpad_right', + [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' +}; + export default function Shortcuts (data: { centerElement?: any; }) { - const iconMap: Record = { - [GamePadButtonCode.A]: 'steamdeck_button_a', - [GamePadButtonCode.B]: 'steamdeck_button_b', - [GamePadButtonCode.X]: 'steamdeck_button_x', - [GamePadButtonCode.Y]: 'steamdeck_button_y', - [GamePadButtonCode.L1]: 'steamdeck_button_l1', - [GamePadButtonCode.R1]: 'steamdeck_button_r1', - [GamePadButtonCode.L2]: 'steamdeck_button_l2', - [GamePadButtonCode.R2]: 'steamdeck_button_r2', - [GamePadButtonCode.Select]: 'steamdeck_button_guide', - [GamePadButtonCode.Start]: 'steamdeck_button_options', - [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', - [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', - [GamePadButtonCode.Up]: 'steamdeck_dpad_up', - [GamePadButtonCode.Down]: 'steamdeck_dpad_down', - [GamePadButtonCode.Left]: 'steamdeck_dpad_left', - [GamePadButtonCode.Right]: 'steamdeck_dpad_right', - [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' - }; + const keyboardMap: Record = { [GamePadButtonCode.A]: 'ENTER', @@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; }) key={s.button} id={`shortcut-${s.button}`} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> )} @@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; }) key={s.button} id={`shortcut-${s.button}`} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> )} diff --git a/src/mainview/components/SvgIcon.tsx b/src/mainview/components/SvgIcon.tsx index 66a5a26..e449d84 100644 --- a/src/mainview/components/SvgIcon.tsx +++ b/src/mainview/components/SvgIcon.tsx @@ -1,5 +1,6 @@ import "virtual:svg-icons/register"; import { StaticAssetPath } from "../gen/static-icon-assets.gen"; +import { CSSProperties } from "react"; type OnlySvgIcon = T extends `${infer Rest}.svg` ? Rest @@ -15,17 +16,19 @@ export default function SvgIcon ({ icon, prefix = "icon", className, + style, ...props }: { icon: IconType; prefix?: string; className?: string; + style?: CSSProperties; }) { const symbolId = `#${prefix}-${icon}`; return ( - : , content: deleteMutation.isPending ? "Deleting" : "Delete", @@ -98,12 +101,16 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { contextOptions.push({ id: "fix_source", - async action (ctx) + action (ctx) { if (!data.game) return; - await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }, { + onSuccess (data, variables, onMutateResult, context) + { + router.navigate({ replace: true }); + }, + }); ctx.close(); - router.navigate({ replace: true }); }, icon: fixMutation.isPending ? : , content: "Try Fix Source", @@ -126,6 +133,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, content: "Update Metadata", type: "primary" }); + + contextOptions.push({ + id: 'update-custom', + action (ctx) + { + ctx.close(); + navigate({ to: '/game/update/$source/$id', params: { source: data.source, id: data.id } }); + }, + icon: updateMutation.isPending ? : , + content: "Update Metadata (Interactive)", + type: "primary" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/GameLookup.tsx b/src/mainview/components/game/GameLookup.tsx new file mode 100644 index 0000000..da38987 --- /dev/null +++ b/src/mainview/components/game/GameLookup.tsx @@ -0,0 +1,80 @@ +import { gameLookup } from "@/mainview/scripts/queries/romm"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import HeaderSearchField from "../HeaderSearchField"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; +import { FOCUS_KEYS } from "@/mainview/scripts/types"; + +function Result (data: { + match: GameLookup; + showPlatform: boolean; + selected: boolean; +} & InteractParams) +{ + const { ref, focusKey } = useFocusable({ + focusKey: FOCUS_KEYS.GAME_MATCH({ source: data.match.source, id: data.match.id }), + onFocus (l, p, d) { scrollIntoViewHandler({ block: 'center' })(focusKey, ref.current, d); }, + onEnterPress (p, d) { data.onAction?.({ focusKey }); } + }); + useShortcuts(focusKey, () => [{ + label: "Select", action (e) + { + data.onAction?.({ event: e, focusKey }); + }, button: GamePadButtonCode.A + }]); + return
  • data.onAction?.({ event: e.nativeEvent, focusKey })} className='flex gap-4 items-center not-mobile:drop-shadow-md light:bg-base-100 dark:bg-base-300 p-2 rounded-2xl focusable focusable-primary focusable-hover cursor-pointer'> + {data.match.coverUrl ?
    + + {data.selected && } +
    :
    } +
    +
    {data.match.name}
    +
    {data.match.summary}
    +
      + {data.showPlatform && <> + {data.match.platforms.map(p =>
    • {p.name}
    • )} +
      + } + {data.match.genres.map(g =>
    • {g}
    • )} + {data.match.first_release_date &&
    • {new Date(data.match.first_release_date).toDateString()}
    • } +
    +
    +
  • ; +} + +function SearchField (data: { setSearch: (search: string | undefined) => void; search: string | undefined; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: `search-field-section` }); + return
    + + data.setSearch(v)} search={data.search} id='search-field' /> + +
    ; +} + +export default function GameLookup (data: { + search: string | undefined, + setSearch: (search: string | undefined) => void, + onSelect: (match: GameLookup) => void; + showPlatforms?: boolean; + selected?: FrontEndId; +}) +{ + const { data: lookups, isFetching } = useQuery({ ...gameLookup(data.search), staleTime: 1000 * 60 * 60 }); + + return
    + +
    {isFetching ? : }Results
    +
      + {lookups?.map((l, i) => + { + return + { + data.onSelect(l); + }} />; + })} +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 96a120f..51de536 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -10,6 +10,7 @@ import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview import ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; import { DownloadSourceType } from "@/shared/constants"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { @@ -118,10 +119,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }; let mainButton: any | undefined = undefined; + let showAllCommandsAction: ((focusKey: string) => void) | undefined; + let mainAction: () => void; if (status === 'installed') { + if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey); + mainAction = () => handlePlay(validDefaultCommand); mainButton =
    - handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + - {validCommands.length > 1 && - showAllCommands(true, 'allActionsBtn')}> + {showAllCommandsAction && + showAllCommandsAction!('allActionsBtn')}> }
    ; } else if (error) { + mainAction = () => + { + if (status === 'missing-emulator') + { + router.navigate({ to: '/settings/directories' }); + } + }; mainButton = - { - if (status === 'missing-emulator') - { - router.navigate({ to: '/settings/directories' }); - } - }} + onAction={mainAction} id="mainAction"> ; @@ -167,26 +173,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { icon = ; } + mainAction = () => + { + if (installMut.isPending) return; + switch (status) + { + case 'present': + case 'install': + if (installSources && installSources.length > 1) + { + showInstallSource(true, 'mainAction'); + } else + { + installMut.mutate({}); + } + + break; + } + }; mainButton = - { - if (installMut.isPending) return; - switch (status) - { - case 'present': - case 'install': - if (installSources && installSources.length > 1) - { - showInstallSource(true, 'mainAction'); - } else - { - installMut.mutate({}); - } - - break; - } - }} + onAction={mainAction} tooltip={details ?? status} type='primary' id="mainAction"> @@ -194,6 +201,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so ; } + useShortcuts('mainAction', () => + { + const shortcuts: Shortcut[] = [{ + button: GamePadButtonCode.A, + action: mainAction + }]; + + if (showAllCommandsAction) + shortcuts.push( + { + button: GamePadButtonCode.Y, + label: "All Commands", + action (e) + { + showAllCommandsAction('mainAction'); + }, + }); + + return shortcuts; + }, [showAllCommandsAction, mainAction]); + const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { content: { diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index bfeb63d..339bcc9 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; -import { useMutation } from "@tanstack/react-query"; -import { changeDownloadsMutation } from "@queries/settings"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { changeDownloadsMutation, getSettingQuery } from "@queries/settings"; +import { SettingsType } from "@/shared/constants"; -export default function DownloadDirectoryOption (data: PathSettingsOptionParams) +export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); + const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const setSettingMutation = useMutation({ ...changeDownloadsMutation, onSuccess: (d, v, r, cx) => @@ -25,6 +27,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams) requireConfirmation={data.requireConfirmation} isDirectoryPicker={true} localValue={localValue} + defaultValue={defaultValue as any} setLocalValue={(v) => { setLocalValue(v); diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 8636bf1..d596123 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -1,4 +1,4 @@ -import { HTMLInputTypeAttribute, JSX } from "react"; +import { JSX } from "react"; import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; @@ -6,14 +6,9 @@ import { useLocalStorage } from "usehooks-ts"; import { OptionDropdown } from "./OptionDropdown"; export function LocalOption (data: { - label: string; id: keyof LocalSettingsType; - type: HTMLInputTypeAttribute | 'dropdown'; - min?: number; - max?: number; step?: number; placeholder?: string; - values?: string[]; icon?: JSX.Element; children?: any; }) @@ -22,9 +17,20 @@ export function LocalOption (data: { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) }); + const schema = LocalSettingsSchema.shape[data.id].toJSONSchema(); + const typeMapping: Record = { + string: 'text', + integer: 'range', + number: 'range', + boolean: 'checkbox' + }; + return ( - - {data.type === 'dropdown' && data.values && +
    {schema.title ?? data.id}
    +
    {schema.description}
    + }> + {!!schema.enum && String(v))} icon={data.icon} name={data.id ?? ""} placeholder={data.placeholder} defaultValue={localValue} @@ -33,12 +39,12 @@ export function LocalOption (data: { setLocalValue(v); }} value={localValue} />} - {data.type !== 'dropdown' && diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx index 3a9b05c..1a1ce2d 100644 --- a/src/mainview/components/options/OptionSpace.tsx +++ b/src/mainview/components/options/OptionSpace.tsx @@ -35,6 +35,7 @@ export function useOptionContext (params?: { onOptionEnterPress?: () => void; }) export function OptionSpace (data: { id?: string; className?: string; + innerClassName?: string; focusable?: boolean; children?: any | any[]; label?: string | JSX.Element | ((focused: boolean) => JSX.Element); @@ -90,7 +91,7 @@ export function OptionSpace (data: { {!!labelElement &&
    {labelElement}
    } -
    +
    {data.children}
    diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 39c57f9..d81d32f 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -13,7 +13,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings"; export interface PathSettingsOptionParams { label: string; - id: KeysWithValueAssignableTo; + id: string; type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; @@ -24,10 +24,11 @@ export interface PathSettingsOptionParams allowNewFolderCreation?: boolean; } -export function PathSettingsOption (data: PathSettingsOptionParams) +export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); + const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const setMutation = useMutation({ ...setSettingMutation(data.id), onSuccess: (d, v, r, cx) => @@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams) save={setMutation.mutate} localValue={localValue} allowNewFolderCreation={data.allowNewFolderCreation} + defaultValue={defaultValue as any} setLocalValue={(v) => { setLocalValue(v); @@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { localValue: string | undefined; setLocalValue: (value: string | undefined) => void; isDirty: boolean; + className?: string; + defaultValue: string | undefined; }) { const [isBrowsing, setIsBrowsing] = useState(false); - const { data: defaultValue } = useQuery(getSettingQuery(data.id)); - const changed = defaultValue !== data.localValue; + const changed = data.defaultValue !== data.localValue; useEffect(() => { - data.setLocalValue(String(defaultValue)); - }, [defaultValue]); + data.setLocalValue(String(data.defaultValue ?? '')); + }, [data.defaultValue]); const handleSelectPath = (path: string) => { @@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { }; return ( - {data.label}{changed && }}> + {data.label}{changed && }}> + - {data.requireConfirmation === true && } -
    ); @@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({ onSelect, }: { emulators: FrontEndEmulator[]; - onSelect?: (id: string, focusKey: string) => void; + onSelect?: (em: FrontEndEmulator, focusKey: string) => void; }) { const { ref, focusKey } = useFocusable({ diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 4d647d1..ad674ce 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -19,6 +19,7 @@ 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 GameAddRouteImport } from './../routes/game/add' 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' @@ -30,6 +31,7 @@ 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' +import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' const GamesRoute = GamesRouteImport.update({ id: '/games', @@ -81,6 +83,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRouteRoute, } as any) +const GameAddRoute = GameAddRouteImport.update({ + id: '/game/add', + path: '/game/add', + getParentRoute: () => rootRouteImport, +} as any) const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ id: '/store/tab', path: '/store/tab', @@ -136,12 +143,18 @@ const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ path: '/store/details/emulator/$id', getParentRoute: () => rootRouteImport, } as any) +const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({ + id: '/game/update/$source/$id', + path: '/game/update/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -158,12 +171,14 @@ export interface FileRoutesByFullPath { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -180,6 +195,7 @@ export interface FileRoutesByTo { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRoutesById { @@ -188,6 +204,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute @@ -204,6 +221,7 @@ export interface FileRoutesById { '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute } export interface FileRouteTypes { @@ -213,6 +231,7 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -229,12 +248,14 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' fileRoutesByTo: FileRoutesByTo to: | '/' | '/settings' | '/games' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -251,6 +272,7 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab' + | '/game/update/$source/$id' | '/store/details/emulator/$id' id: | '__root__' @@ -258,6 +280,7 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' @@ -274,6 +297,7 @@ export interface FileRouteTypes { | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' fileRoutesById: FileRoutesById } @@ -282,11 +306,13 @@ export interface RootRouteChildren { SettingsRouteRoute: typeof SettingsRouteRouteWithChildren GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren + GameAddRoute: typeof GameAddRoute CollectionSourceIdRoute: typeof CollectionSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute PlatformSourceIdRoute: typeof PlatformSourceIdRoute + GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute } @@ -362,6 +388,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRouteRoute } + '/game/add': { + id: '/game/add' + path: '/game/add' + fullPath: '/game/add' + preLoaderRoute: typeof GameAddRouteImport + parentRoute: typeof rootRouteImport + } '/store/tab': { id: '/store/tab' path: '/store/tab' @@ -439,6 +472,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport parentRoute: typeof rootRouteImport } + '/game/update/$source/$id': { + id: '/game/update/$source/$id' + path: '/game/update/$source/$id' + fullPath: '/game/update/$source/$id' + preLoaderRoute: typeof GameUpdateSourceIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -489,11 +529,13 @@ const rootRouteChildren: RootRouteChildren = { SettingsRouteRoute: SettingsRouteRouteWithChildren, GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, + GameAddRoute: GameAddRoute, CollectionSourceIdRoute: CollectionSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute, PlatformSourceIdRoute: PlatformSourceIdRoute, + GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, } export const routeTree = rootRouteImport diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index d0a346e..86c551b 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -24,6 +24,7 @@ import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; import { en } from "zod/v4/locales"; +import { IGDBIcon } from "@/mainview/scripts/brandIcons"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -105,6 +106,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + if (data.game.igdb_id) + stats.push({ label: "IGDB", icon: IGDBIcon, content: String(data.game.igdb_id) }); if (data.game.source) stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx new file mode 100644 index 0000000..d741765 --- /dev/null +++ b/src/mainview/routes/game/add.tsx @@ -0,0 +1,396 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import { OptionElement } from '@/mainview/components/ContextDialog'; +import GameLookup from '@/mainview/components/game/GameLookup'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { oneShot } from '@/mainview/scripts/audio/audio'; +import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, FileSearch, FolderOpen, HardDrive } from 'lucide-react'; +import { basename } from 'pathe'; +import { JSX, useState } from 'react'; +import toast from 'react-hot-toast'; +import { twMerge } from 'tailwind-merge'; +import z from 'zod'; + + +const StateSchema = z.object({ + step: z.number().default(0), + gameLocation: z.string().optional(), + selectedGame: z.object({ source: z.string(), id: z.string() }).optional(), + platformId: z.number().optional(), + search: z.string().optional() +}); + +export const Route = createFileRoute('/game/add')({ + component: RouteComponent, + validateSearch: zodValidator(StateSchema) +}); + +function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) +{ + const [localLocation, setLocalLocation] = useState(data.location); + return ; +} + +const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; +const EXTENSION_REGEX = /\.(([a-z]+\.)*\w+)$/g; +const LEADING_ARTICLE_PATTERN = /^(a|an|the)\b/g; +const COMMA_ARTICLE_PATTERN = /,\s(a|an|the)\b(?=\s*[^\w\s]|$)/g; +const NON_WORD_SPACE_PATTERN = /[^\w\s]/g; +const MULTIPLE_SPACE_PATTERN = /\s+/g; + +function BuildSearch (filePath: string) +{ + const name = basename(filePath); + const nameWithoutExt = name.replace(EXTENSION_REGEX, "").trim(); + if (!nameWithoutExt) return undefined; + const nameWithoutTags = nameWithoutExt.replaceAll(TAG_REGEX, "").trim(); + if (TAG_REGEX.test(nameWithoutExt)) console.log("match"); + if (!nameWithoutTags) return undefined; + + // Lower and replace underscores with spaces + let finalSearch = nameWithoutTags.toLowerCase().replace("_", " "); + + // Remove articles (combined if possible) + finalSearch = finalSearch.replaceAll(LEADING_ARTICLE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(COMMA_ARTICLE_PATTERN, ''); + + // Remove punctuation and normalize spaces in one step + finalSearch = finalSearch.replaceAll(NON_WORD_SPACE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(MULTIPLE_SPACE_PATTERN, ''); + + return nameWithoutTags; +} + +const typeIconMap: Record = { + new: , + existing: , + unknown: +}; + +function Overview (data: {}) +{ + const navigate = useNavigate(); + const router = useRouter(); + const state = Route.useSearch(); + const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id)); + const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); + const addGame = useMutation({ + ...addManualGameMutation, + onError (error, variables, onMutateResult, context) + { + toast.error(error.message); + }, + async onSuccess (data, variables, onMutateResult, context) + { + if (data.id === null) return; + await context.client.invalidateQueries(allGamesInvalidateQuery); + navigate({ + to: '/game/$source/$id', params: { + source: data.source, id: String(data.id) + }, replace: true + }); + }, + }); + + if (!game) return
    Select A Game
    ; + + return
    +
    Preview
    +
    +
    {!!game[0].coverUrl && }
    +
    +
    {game[0].name}
    +
    {game[0].summary}
    +
    +
    {platform?.details.name}
    + +
    + {!!platform?.match.coverUrl && } +
    {platform?.match.name}
    +
    {platform?.match.family_name}
    + + {!!platform?.match.type && typeIconMap[platform?.match.type]} +
    {platform?.match.type}
    +
    +
    +
    {state.gameLocation}
    +
    +
    +
    Actions
    +
    + + +
    +
    ; +} + +function PlatformEntry (data: { + id: string, + displayName: string, + platformSource: string, + platformId: number; +}) +{ + const state = Route.useSearch(); + const { data: match, isFetching: matchIsFetching } = useQuery({ ...platformLookupMatchQuery(data.platformSource, data.platformId), staleTime: 1000 * 60 * 60 }); + const navigate = useNavigate(); + const handleAction = () => + { + navigate({ to: '/game/add', search: { ...state, platformId: data.platformId, step: 3 }, replace: true }); + oneShot('openGeneric'); + }; + + return +
    {data.displayName}
    +
    + {matchIsFetching ? : match && <> + + {match.match.coverUrl ? : } +
    {match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}
    + } + + } type={'primary'} />; +} + +function PlatformSelection (data: {}) +{ + const state = Route.useSearch(); + const { data: game, isFetching } = useQuery({ ...gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id), staleTime: 1000 * 60 * 60 }); + if (isFetching) return ; + if (!game) return
    Select A Game
    ; + return
      + {game[0].platforms.map((p, i) => )} +
    ; +} + +function Lookup () +{ + const state = Route.useSearch(); + const [search, setSearch] = useState(state.search); + const navigate = useNavigate(); + const handleSetSelectedGame = (source: string, id: string) => + { + navigate({ to: '/game/add', search: { ...state, selectedGame: { source, id }, platformId: undefined, search, step: 2 }, replace: true }); + oneShot('openGeneric'); + }; + return + { + handleSetSelectedGame(l.source, l.id); + }} />; +} + +const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { label: "Select Platform" }, { label: "Confirm" }]; + +function Location () +{ + + const state = Route.useSearch(); + const navigate = useNavigate(); + const handleSetLocation = (location: string | undefined) => + { + if (!location) return; + navigate({ + to: '/game/add', search: { + ...state, + gameLocation: location, + search: BuildSearch(location), + selectedGame: undefined, + platformId: undefined, + step: 1 + }, replace: true + }); + oneShot('openGeneric'); + }; + return
    +
    Select Game Rom
    + +
    + Select The Rom File from your local storage +
    +
    ; +} + +function Details (data: {}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' }); + const state = Route.useSearch(); + const step = state.step ?? 0; + return
    + + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + + +
    ; +} + +function getStepDetails (index: number, state: z.infer) +{ + let completed = index < state.step; + if (index === 0 && state.gameLocation) completed = true; + if (index === 1 && state.selectedGame) completed = true; + if (index === 2 && state.platformId) completed = true; + if (index === 3 && state.gameLocation && state.selectedGame && state.platformId) completed = true; + let canNavigate = index <= state.step; + if (index === 1 && state.gameLocation) canNavigate = true; + if (index === 2 && state.selectedGame) canNavigate = true; + if (index === 3 && state.platformId) canNavigate = true; + return { completed, canNavigate }; +} + +function Step (data: { index: number; label: string; }) +{ + const navigate = useNavigate(); + const handleGoToStep = (step: number) => + { + navigate({ to: '/game/add', search: { ...state, step: step }, replace: true }); + oneShot('openGeneric'); + }; + const state = Route.useSearch(); + const step = state.step ?? 0; + const { canNavigate, completed } = getStepDetails(data.index, state); + + const { ref } = useFocusable({ + focusKey: `step-${data.index}`, + focusable: canNavigate, + onFocus: () => + { + if (step === data.index) return; + navigate({ to: '/game/add', search: { ...state, step: data.index }, replace: true }); + oneShot('openGeneric'); + } + }); + return
  • + { + if (!canNavigate) return; + handleGoToStep(data.index); + }} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}> + {completed ? : } + {data.label} +
  • ; +} + +function Steps () +{ + const state = Route.useSearch(); + const step = state.step ?? 0; + const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false }); + return
      + + {StepDetails.map((s, i) => )} + +
    ; +} + +function RouteComponent () +{ + const navigate = useNavigate(); + const state = Route.useSearch(); + const step = state.step ?? 0; + const router = useRouter(); + const queryClient = useQueryClient(); + const isAddingGame = queryClient.isMutating(addManualGameMutation) > 0; + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'add-game-page', preferredChildFocusKey: 'steps' }); + + const handleReturnStep = (e: Event) => + { + if (step <= 0) + { + HandleGoBack(router, e); + } else + { + const newStep = step - 1; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + } + }; + + const handleStepNavigation = (newStep: number) => + { + if (step === newStep) return; + const { canNavigate } = getStepDetails(newStep, state); + if (!canNavigate) return; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + oneShot('openGeneric'); + }; + + useShortcuts(focusKey, () => [ + { button: GamePadButtonCode.B, label: step === 0 ? "Cancel" : "Prev Step", action: handleReturnStep }, + { button: GamePadButtonCode.Y, label: "Cancel", action: e => HandleGoBack(router, e) }, + { + button: GamePadButtonCode.L1, label: "Prev Step", action (e) + { + handleStepNavigation(Math.max(step - 1, 0)); + }, + }, + { + button: GamePadButtonCode.R1, label: "Next Step", action (e) + { + handleStepNavigation(Math.min(step + 1, 3)); + }, + } + ], [step]); + + return
    + +
    + +
    + +
    +
    + +
    +
    + + {isAddingGame && +
    + +
    Adding Game
    +
    +
    } +
    ; +} diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx new file mode 100644 index 0000000..1275a8d --- /dev/null +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -0,0 +1,61 @@ +import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import GameLookup from '@/mainview/components/game/GameLookup'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; + +export const Route = createFileRoute('/game/update/$source/$id')({ + component: RouteComponent, +}); + +function RouteComponent () +{ + const { source, id } = Route.useParams(); + const [search, setSearch] = useState(undefined); + const navigate = useNavigate(); + + const router = useRouter(); + const { data: game } = useQuery(gameQuery(source, id)); + const update = useMutation({ + ...customUpdateMutation, + async onSuccess (data, variables, onMutateResult, context) + { + toast.success("Updated Metadata"); + await context.client.invalidateQueries(gameInvalidationQuery(source, id)); + router.history.back(); + }, + }); + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `custom-update-page`, preferredChildFocusKey: 'search-field-section' }); + + useShortcuts(focusKey, () => [{ button: GamePadButtonCode.B, label: "Return", action (e) { HandleGoBack(router, e); }, }]); + useEffect(() => + { + if (search) return; + setSearch(game?.name ?? undefined); + }, [game]); + + return + +
    + + + update.mutate({ source, id, destination: l.source, destinationId: l.id })} + /> + + +
    +
    +
    ; +} diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index 3742e83..e6fd86e 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -1,12 +1,13 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { GameListFilterType } from '@/shared/constants'; import { useSessionStorage } from 'usehooks-ts'; import HeaderSearchField from '../components/HeaderSearchField'; -import { useEffect, useState } from 'react'; -import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; +import { useEffect } from 'react'; +import { RoundButton } from '../components/RoundButton'; +import { Plus } from 'lucide-react'; export const Route = createFileRoute('/games')({ component: RouteComponent, @@ -21,6 +22,7 @@ function RouteComponent () const { focus } = Route.useSearch(); const { search } = Route.useSearch(); const [filter, setFilter] = useSessionStorage('all-games-filters', {}); + const navigate = useNavigate(); useEffect(() => { @@ -28,7 +30,13 @@ function RouteComponent () }, [search]); return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} + headerButtonElements={ + [ + { + navigate({ to: '/game/add' }); + }} >, + setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />] + } localFilter={filter} setLocalFilter={setFilter} focus={focus} diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index e9feb92..73d0265 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants"; +import { GameListFilterType, RPC_URL } from "../../shared/constants"; import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; @@ -22,7 +22,7 @@ function PlatformTitle (data: {}) const { source, id } = Route.useParams(); const { data: platform } = useQuery(platformQuery(source, id)); - return
    + return
    {!!platform && } @@ -36,9 +36,10 @@ function RouteComponent () const { source, id } = Route.useParams(); const router = useRouter(); const { countHint } = Route.useSearch(); + const { data: platform } = useQuery(platformQuery(source, id)); const [filter, setFilter] = useLocalStorage("platforms-filters", {}); const updatePlatform = useMutation({ - ...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context) + ...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context) { context.client.invalidateQueries(localPlatformFilter(id)); }, @@ -56,7 +57,7 @@ function RouteComponent () }, }); const settingsOptions: DialogEntry[] = []; - if (source === 'local') + if (source === 'local' || platform?.hasLocal) { settingsOptions.push({ id: 'update-platform', @@ -70,7 +71,10 @@ function RouteComponent () router.navigate({ replace: true }); }, }); + } + if (source === 'local') + { settingsOptions.push({ id: 'update-platform', type: "error", @@ -97,7 +101,7 @@ function RouteComponent () icon: , action () { - setPlatformSettingsOpen(true); + setPlatformSettingsOpen(true, 'open-platform-settings-btn'); }, }]} countHint={countHint} diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 363ef45..f0e9360 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react'; +import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; @@ -80,7 +80,10 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd }; - return + return +
    Custom Emulator Path
    +
    Manually Pick a path to an emulator if not automatically found.
    +
    }>