feat: Implemented local game import (with a wizard)

feat: Implemented a radial virtual gamepad keyboard.
fix: Fixed shortcuts for file explorer
This commit is contained in:
Simeon Radivoev 2026-05-04 14:59:43 +03:00
parent e54a6ac8f0
commit 06b7e4074d
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
66 changed files with 2216 additions and 416 deletions

View file

@ -12,6 +12,8 @@ Focused on building a simple user experience and intuitive UI as a curated commu
### Integrations ### Integrations
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. - **[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. - **[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. - **[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) - **[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 ## Screenshots
<img src=".github/screenshots/Pkazk0RufB.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img> <img src=".github/screenshots/3d screenshot.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img>
<img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img> <img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img>
<img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img> <img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
<img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img> <img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>

View file

@ -43,7 +43,7 @@
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
"@emulatorjs/emulatorjs": "^4.2.3", "@emulatorjs/emulatorjs": "^4.2.3",
"@hey-api/openapi-ts": "^0.91.0", "@hey-api/openapi-ts": "^0.91.0",
"@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.28.0", "@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=="], "@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=="], "@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": ["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.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="], "lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="],

View file

@ -84,7 +84,7 @@
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
"@emulatorjs/emulatorjs": "^4.2.3", "@emulatorjs/emulatorjs": "^4.2.3",
"@hey-api/openapi-ts": "^0.91.0", "@hey-api/openapi-ts": "^0.91.0",
"@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.28.0", "@tanstack/react-form": "^1.28.0",

View file

@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { InstallJob } from "../jobs/install-job"; import { InstallJob } from "../jobs/install-job";
import path from "node:path"; import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; 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 { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService"; import { launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { getErrorMessage, SeededRandom } from "@/bun/utils";
@ -21,6 +21,7 @@ import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job"; import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs"; import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job";
// A custom jimp that supports webp // A custom jimp that supports webp
const Jimp = createJimp({ const Jimp = createJimp({
@ -491,6 +492,24 @@ export default new Elysia()
{ {
return update(source, id); 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 }) => .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{ {
const validCommands = await getValidLaunchCommandsForGame(source, id); const validCommands = await getValidLaunchCommandsForGame(source, id);
@ -651,4 +670,17 @@ export default new Elysia()
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank); rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10); 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()
})
}); });

View file

@ -1,8 +1,9 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import z from "zod"; import z from "zod";
import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
import { db, plugins } from "../app"; import { config, db, plugins } from "../app";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import { findPlatform } from "./services/utils";
export default new Elysia() export default new Elysia()
.get('/platforms', async () => .get('/platforms', async () =>
@ -91,7 +92,8 @@ export default new Elysia()
{ {
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (!remotePlatform) return status("Not Found"); 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() }) }) }, { params: z.object({ source: z.string(), id: z.string() }) })
.get('/platform/local/:id/cover', async ({ params: { id }, set }) => .get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
@ -114,15 +116,31 @@ export default new Elysia()
} }
return status(200, coverBlob.cover); return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) }, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 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"); if (!localPlatform) return status("Not Found");
const platformLookup = await plugins.hooks.games.platformLookup.promise({ const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: localPlatform.slug 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) if (!platformCover.ok && platformLookup?.url_logo)
{ {
platformCover = await fetch(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))) .where(eq(schema.games.platform_id, Number(id)))
))).returning(); ))).returning();
if (deleted.length <= 0) return status("Not Found"); 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."
}
}); });

View file

@ -4,7 +4,7 @@ import { getErrorMessage } from "@/bun/utils";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import Elysia from "elysia"; import Elysia from "elysia";
import z, { string } from "zod"; import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job"; import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
@ -41,6 +41,63 @@ export async function getLocalGame (source: string, id: string)
return localGame; 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<ArrayBuffer> | 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) export async function update (source: string, id: string)
{ {
const localGame = await getLocalGame(source, id); 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}`)]; const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id) if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{ {
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); const matches: GameLookup[] = [];
if (igdbLookup) 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);
} }
} }

View file

@ -2,23 +2,25 @@ import getFolderSize from "get-folder-size";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { config, db, emulatorsDb, plugins } from "../../app"; 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 * as schema from "@schema/app";
import { RPC_URL, StoreGameType } from "@shared/constants"; import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils"; import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host"; import { host } from "@/bun/utils/host";
import secrets from "../../secrets"; import * as emulatorSchema from "@schema/emulators";
export async function calculateSize (installPath: string | null) export async function calculateSize (installPath: string | null)
{ {
if (!installPath) return 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) export async function checkInstalled (installPath: string | null)
{ {
if (!installPath) return false; 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) export function getScreenshotLocalGameMatch (id: string, source: string)
@ -172,3 +174,296 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; 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<ArrayBufferLike> | 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;
}

View file

@ -1,5 +1,5 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook } from 'tapable';
export class GameHooks export class GameHooks
{ {
@ -95,7 +95,12 @@ export class GameHooks
name?: string; name?: string;
family_name?: string; family_name?: string;
} | undefined>(['ctx']); } | 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: { fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[]; platforms: FrontEndPlatformType[];
}]>(['ctx']); }]>(['ctx']);

View file

@ -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<z.infer<typeof ImportJob.dataSchema>, 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<typeof ImportJob.dataSchema>
{
return { localId: this.localId };
}
async start (context: JobContext<IJob<z.infer<typeof ImportJob.dataSchema>, string>, z.infer<typeof ImportJob.dataSchema>, string>): Promise<any>
{
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<ArrayBufferLike> | 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,
}
});
}
}

View file

@ -1,17 +1,12 @@
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "../task-queue";
import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import * as schema from "@schema/app"; import path from 'node:path';
import * as emulatorSchema from "@schema/emulators"; import { config, events, plugins } from "../app";
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 { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader"; import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z'; import Seven from 'node-7z';
import z from "zod"; import z from "zod";
import { checkFiles } from "../games/services/utils"; import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir, move } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip'; import StreamZip from 'node-stream-zip';
@ -37,6 +32,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
// The local game ID of newly created entry, if successful // The local game ID of newly created entry, if successful
public localGameId?: number; public localGameId?: number;
public group = InstallJob.id; public group = InstallJob.id;
public localPath?: string;
constructor(id: string, source: string, config?: JobConfig) constructor(id: string, source: string, config?: JobConfig)
{ {
@ -51,18 +47,19 @@ export class InstallJob implements IJob<never, InstallJobStates>
await fs.mkdir(config.get('downloadPath'), { recursive: true }); await fs.mkdir(config.get('downloadPath'), { recursive: true });
const downloadPath = config.get('downloadPath'); const downloadPath = config.get('downloadPath');
const finalFiles: string[] = [];
let info: DownloadInfo | undefined; 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 }); const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0]; info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`); if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path); const files = await checkFiles(info.files, !!info.extract_path);
const finalFiles: string[] = [];
if (this.config?.dryRun !== true)
{
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{ {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@ -197,143 +194,32 @@ export class InstallJob implements IJob<never, InstallJobStates>
if (cx.abortSignal.aborted) return; if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) => this.localGameId = await createLocalGame({
{ cover,
// Search for existing platform coverType: coverResponse.headers.get('content-type'),
const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; system_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_id: info.source_id,
source: this.source, source: this.source,
slug: info.slug, slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
last_played: info.last_played, summary: info.summary,
platform_id: platformId,
igdb_id: info.igdb_id, igdb_id: info.igdb_id,
ra_id: info.ra_id, ra_id: info.ra_id,
summary: info.summary,
name: info.name, name: info.name,
cover,
cover_type: coverResponse.headers.get('content-type'),
metadata: info.metadata,
main_glob: info.main_glob, main_glob: info.main_glob,
version: info.version, version: info.version,
version_source: info.version_source, version_source: info.version_source,
version_system: info.version_system screenshotUrls: info.screenshotUrls,
}; version_system: info.version_system,
metadata: info.metadata,
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); platform: info.platform
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;
}); });
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 } else
{ {
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); 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 });
} }
} }

View file

@ -288,7 +288,7 @@ export default class IgdbIntegration implements PluginType
} }
const downloadPath = config.get('downloadPath'); 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 }); 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 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 }); return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
}); });

View file

@ -4,7 +4,7 @@
"version": "0.0.1", "version": "0.0.1",
"description": "Rclone integration for syncing saves", "description": "Rclone integration for syncing saves",
"main": "./rclone.ts", "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", "category": "saves",
"keywords": [ "keywords": [
"integration", "integration",

View file

@ -42,17 +42,53 @@ export default class IgdbIntegration implements PluginType
{ {
await checkLoginAndRefreshTwitch(); 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 (!process.env.TWITCH_CLIENT_ID) return;
if (source !== 'igdb') return;
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); 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 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;
} }
}); });

View file

@ -4,7 +4,7 @@
"version": "0.0.1", "version": "0.0.1",
"description": "IGDB Metadata Integration", "description": "IGDB Metadata Integration",
"main": "./igdb.ts", "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", "category": "sources",
"keywords": [ "keywords": [
"integration", "integration",

View file

@ -253,6 +253,8 @@ export default class RommIntegration implements PluginType<SettingsType>
const info: DownloadInfo = { const info: DownloadInfo = {
platform: { platform: {
source: 'romm',
id: String(rommPlatform.id),
slug: rommPlatform.slug, slug: rommPlatform.slug,
name: rommPlatform.name, name: rommPlatform.name,
family_name: rommPlatform.family_name ?? undefined family_name: rommPlatform.family_name ?? undefined

View file

@ -1,6 +1,6 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json'; 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 { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL } from "bun"; import { Glob, pathToFileURL } from "bun";
import { and, eq } from "drizzle-orm"; 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 UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { path7za } from "7zip-bin";
export default class RommIntegration implements PluginType export default class RommIntegration implements PluginType
{ {
@ -314,6 +313,8 @@ export default class RommIntegration implements PluginType
version_system: validDownload.system, version_system: validDownload.system,
version_source: validDownload.id, version_source: validDownload.id,
platform: { platform: {
source: 'store',
id: system,
slug: system, slug: system,
name: system name: system
} }

View file

@ -12,15 +12,7 @@ export const games = sqliteTable('games', {
main_glob: text("main_glob"), main_glob: text("main_glob"),
last_played: integer("last_played", { mode: 'timestamp' }), last_played: integer("last_played", { mode: 'timestamp' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<LocalGameMetadata>().notNull(),
genres?: string[],
companies?: string[],
game_modes?: string[],
age_ratings?: string[];
player_count?: string;
first_release_date?: number;
average_rating?: number;
}>().notNull(),
slug: text("slug").unique(), slug: text("slug").unique(),
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
cover: blob("cover", { mode: 'buffer' }), cover: blob("cover", { mode: 'buffer' }),

View file

@ -57,15 +57,6 @@ export async function getRelevantEmulators ()
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
const integrations = findEmulatorPluginIntegration(emulator, 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; let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system); const validSystemSlug = system_slug.find(s => s.system);
if (validSystemSlug?.system) if (validSystemSlug?.system)
@ -78,7 +69,17 @@ export async function getRelevantEmulators ()
systems.forEach(s => platformViability.set(s, true)); 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; } = { const em: FrontEndEmulator & { isCritical: boolean; } = {
source: 'local',
name: emulator, name: emulator,
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', 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 ?? '' })), 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({ finalEmulators.push({
source: 'local',
name: 'EMULATORJS', name: 'EMULATORJS',
validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }], validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,

View file

@ -2,7 +2,7 @@ import Elysia from "elysia";
import open from 'open'; import open from 'open';
import z from "zod"; import z from "zod";
import os from 'node:os'; import os from 'node:os';
import { cache, cachePath, config, events, taskQueue } from "./app"; import { cachePath, config, events, taskQueue } from "./app";
import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import { getAppVersion, isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications"; import buildNotificationsStream from "./notifications";
@ -14,7 +14,7 @@ import si from 'systeminformation';
import { getStoreFolder } from "./store/services/gamesService"; import { getStoreFolder } from "./store/services/gamesService";
import ReloadPluginsJob from "./jobs/reload-plugins-job"; import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun"; import { semver } from "bun";
import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache"; import { getOrCachedGithubRelease } from "./cache";
import SelfUpdateJob from "./jobs/self-update-job"; import SelfUpdateJob from "./jobs/self-update-job";
async function checkUpdate (force?: boolean) async function checkUpdate (force?: boolean)
@ -239,6 +239,10 @@ export const system = new Elysia({ prefix: '/api/system' })
{ {
currentPath = path.resolve(process.cwd(), currentPath); 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 }); const paths = await fs.readdir(currentPath, { withFileTypes: true });
return { return {
name: path.basename(currentPath), name: path.basename(currentPath),

View file

@ -1,8 +1,5 @@
import { and } from 'drizzle-orm';
import EventEmitter from 'node:events'; import EventEmitter from 'node:events';
import z, { any } from 'zod'; import z from 'zod';
export class TaskQueue export class TaskQueue
{ {
@ -10,7 +7,16 @@ export class TaskQueue
private queue?: JobContext<IJob<any, string>, any, string>[] = []; private queue?: JobContext<IJob<any, string>, any, string>[] = [];
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>(); private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
public enqueue<T> (id: string, job: T): T extends IJob<infer TData, infer TState extends string> constructor()
{
// we need a default error listener or app crashes
this.events?.addListener('error', e =>
{
console.error(e);
});
}
public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
? Promise<TData> ? Promise<TData>
: never : never
{ {
@ -35,7 +41,7 @@ export class TaskQueue
{ {
job.job.start(); job.job.start();
this.activeQueue.push(job.job); this.activeQueue.push(job.job);
job.job.promise.promise.finally(() => job.job.promise.promise.catch(e => { }).finally(() =>
{ {
const index = this.activeQueue.indexOf(job.job); const index = this.activeQueue.indexOf(job.job);
this.activeQueue.splice(index, 1); this.activeQueue.splice(index, 1);
@ -234,27 +240,22 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
this.m_promise.resolve(undefined); this.m_promise.resolve(undefined);
} }
} catch (error) } catch (error)
{
try
{ {
if (error instanceof Event) if (error instanceof Event)
{ {
if (error.target instanceof AbortSignal) if (error.target instanceof AbortSignal)
{ {
this.m_promise.resolve(undefined);
} else } else
{ {
console.error(error); console.error(error);
this.m_promise.reject(error);
} }
} else } else
{ {
console.error(error);
this.events.emit('error', { id: this.m_id, job: this, error }); this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error; this.error = error;
} this.m_promise.reject(error);
} finally
{
this.m_promise.resolve(undefined);
} }
} finally } finally

View file

@ -36,28 +36,52 @@
34000, 34000,
2489.5918367346967 2489.5918367346967
], ],
"Classic UI SFX - Chords #16": [ "Classic UI SFX - Short - High #25": [
38000, 38000,
2005.215419501134
],
"Classic UI SFX - Chords #16": [
42000,
4005.215419501134 4005.215419501134
], ],
"Classic UI SFX - Short - High #8": [ "Classic UI SFX - Short - High #8": [
44000, 48000,
2916.6666666666642 2916.6666666666642
], ],
"UI_Single_Set 16_03": [ "UI_Single_Set 16_03": [
48000, 52000,
309.5918367346968 309.5918367346968
], ],
"UI_Single_Set 16_01": [ "UI_Single_Set 16_01": [
50000, 54000,
309.5918367346968 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": [ "Classic UI SFX - Short - Low #6": [
52000, 66000,
2333.3333333333358 2333.3333333333285
], ],
"UI SFX_InGameMenu_Open": [ "UI SFX_InGameMenu_Open": [
56000, 70000,
2614.104308390026 2614.104308390026
] ]
} }

BIN
src/mainview/assets/sounds.ogg (Stored with Git LFS)

Binary file not shown.

View file

@ -3,6 +3,7 @@ import { SystemInfoContext } from "../scripts/contexts";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
import { SystemInfoType } from "@/shared/constants"; import { SystemInfoType } from "@/shared/constants";
import LoadingScreen from "./LoadingScreen"; import LoadingScreen from "./LoadingScreen";
import { GamepadKeyboard } from "./GamepadKeyboard";
export default function AppCommunication (data: { children: any; }) export default function AppCommunication (data: { children: any; })
{ {
@ -55,5 +56,6 @@ export default function AppCommunication (data: { children: any; })
</div> </div>
</LoadingScreen> </LoadingScreen>
: data.children} : data.children}
<GamepadKeyboard />
</SystemInfoContext>; </SystemInfoContext>;
} }

View file

@ -8,10 +8,9 @@ export default function CollectionList (data: {
id: string, id: string,
setBackground: (url: string) => void; setBackground: (url: string) => void;
className?: string; className?: string;
onFocus?: GameCardFocusHandler;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
saveChildFocus?: 'session' | 'local'; saveChildFocus?: 'session' | 'local';
}) } & FocusParams)
{ {
const router = useRouter(); const router = useRouter();
const { data: collections } = useSuspenseQuery(getCollectionsQuery); const { data: collections } = useSuspenseQuery(getCollectionsQuery);
@ -37,7 +36,7 @@ export default function CollectionList (data: {
id: `${g.id.source}@${g.id.id}`, id: `${g.id.source}@${g.id.id}`,
title: g.name, title: g.name,
focusKey: `collection-${g.id}`, focusKey: `collection-${g.id}`,
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
badges: [ badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2"> <span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.game_count} {g.game_count}

View file

@ -1,24 +1,17 @@
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderButton, StickyHeaderUI } from './Header'; import { HeaderButton, StickyHeaderUI } from './Header';
import { GameList } from './GameList'; 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 } from 'react';
import { JSX, Suspense, useRef, useState } from 'react';
import { FloatingShortcuts } from './Shortcuts'; import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus'; import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { GameListFilterType } from '@/shared/constants';
import { HandleGoBack } from '../scripts/utils'; import { HandleGoBack } from '../scripts/utils';
import LoadingCardList from './LoadingCardList'; import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; 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 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'; import SideFilters from './SideFilters';
export interface CollectionsDetailParams export interface CollectionsDetailParams
@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div> <div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
<div className='mobile:hidden bg-noise'></div> <div className='mobile:hidden bg-noise'></div>
<div className='mobile:hidden bg-dots'></div> <div className='mobile:hidden bg-dots'></div>
{finalFilter && data.title} {!!finalFilter && data.title}
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}> {<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
<GameList <GameList
key={`${data.id}-${JSON.stringify(finalFilter)}`} key={`${data.id}-${JSON.stringify(finalFilter)}`}

View file

@ -18,7 +18,7 @@ export function ContextList (data: {
{ {
const context = useContext(ContextDialogContext); const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}> return <ul className={twMerge("list gap-1", data.className)}>
{data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)} {data.options?.map((o, i) => <OptionElement className="list-row" key={`${o.id}-${i}`} {...o} />)}
{data.showCloseButton !== false && <div className="divider m-0 "></div>} {data.showCloseButton !== false && <div className="divider m-0 "></div>}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />} {data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>; </ul>;
@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
}; };
const { ref, focusSelf, focusKey } = useFocusable({ const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
onEnterPress: data.shortcuts ? undefined : handleAction, onEnterPress: handleAction,
onFocus: handleFocus, onFocus: handleFocus,
trackChildren: typeof data.content !== 'string' trackChildren: typeof data.content !== 'string'
}); });

View file

@ -1,9 +1,8 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry } from "./ContextDialog"; import { ContextList, DialogEntry } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { FocusEventHandler, useContext, useRef, useState } from "react"; import { FocusEventHandler, useContext, useRef, useState } from "react";
import path from "pathe"; 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 { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType } from "@/shared/constants"; import { DirType } from "@/shared/constants";
import classNames from "classnames"; import classNames from "classnames";
@ -15,7 +14,6 @@ import toast from "react-hot-toast";
import { FilePickerContext } from "../scripts/contexts"; import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads"; import useActiveControl from "../scripts/gamepads";
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system"; import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
import { showKeyboardHandler } from "../scripts/utils";
function List (data: { function List (data: {
id: string, id: string,
@ -48,7 +46,7 @@ function List (data: {
let icon = <Folder className="text-warning" />; let icon = <Folder className="text-warning" />;
if (isDefaultPath) if (isDefaultPath)
{ {
icon = <FolderInput className="text-warning" />; icon = f.isDirectory ? <FolderInput className="text-accent" /> : <FileInput className="text-accent" />;
} else if (!f.isDirectory) } else if (!f.isDirectory)
{ {
icon = <File />; icon = <File />;
@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName:
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => const handleFocus: FocusEventHandler<HTMLInputElement> = (e) =>
{ {
focusSelf(); focusSelf();
showKeyboardHandler(control as any, e.target);
}; };
return <div className={data.className} ref={ref}> return <div className={data.className} ref={ref}>
<input ref={inputRef} <input ref={inputRef}

View file

@ -0,0 +1,509 @@
import { createRef, JSX, RefObject, useEffect, useRef, useState } from "react";
import useActiveControl from "../scripts/gamepads";
import { oneShot } from "../scripts/audio/audio";
import { ArrowLeft, ArrowRight, CornerDownLeft, Delete, Space } from "lucide-react";
import { GamePadButtonCode } from "../scripts/shortcuts";
import { GamepadIconMap } from "./Shortcuts";
import ShortcutPrompt from "./ShortcutPrompt";
import { getLocalSetting, showKeyboardHandler } from "../scripts/utils";
const Keys = [
['E', 'R', 'T', 'F', 'D', 'G', 'V', 'C', 'S', 'X', 'Z', 'B', 'A', 'Q', 'W'],
['I', '⌫', 'O', '⏎', 'P', 'L', 'N', '␣', 'M', 'J', 'K', 'H', 'Y', 'U']
];
const Characters = [
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "%", "$", "#", "@", "+"],
[",", '⌫', ".", '⏎', "/", "[", "]", '␣', "(", ")", ":", "!", "?", "&"]
];
function GetKeys (characters: boolean)
{
return characters ? Characters : Keys;
}
const KeyColors: Record<string, { bg: string, color: string; }> = {
'⌫': { 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<string, GamePadButtonCode> = {
'⌫': GamePadButtonCode.X,
'␣': GamePadButtonCode.Y,
'⏎': GamePadButtonCode.A,
'←': GamePadButtonCode.Left,
'→': GamePadButtonCode.Right,
'⇧': GamePadButtonCode.RJoy,
'⌥': GamePadButtonCode.LJoy
};
const KeyElements: Record<string, JSX.Element> = {
'⌫': <Delete />,
'␣': <Space />,
'⏎': <CornerDownLeft />,
'←': <ArrowLeft />,
'→': <ArrowRight />,
};
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<HTMLSpanElement | null>[] = [];
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<HTMLSpanElement>();
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(<>
<span key={GetKeys(characters)[side][i]} ref={ref} className='flex absolute bg-base-100 size-8 text-xl items-center justify-center p-1 rounded-full transition-[background,scale]' style={{
left: `calc(50% + ${tr * x}% - 16px)`,
top: `calc(50% + ${tr * y}% - 16px)`,
backgroundColor: KeyColors[GetKeys(characters)[side][i]]?.bg,
color: KeyColors[GetKeys(characters)[side][i]]?.color,
}}>
{KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()}
</span>
</>);
}
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<HTMLInputElement | null>(null);
const circleRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const sideRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const keyIndicatorRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const activeControl = useActiveControl();
const hidden = !focusedInput || activeControl.control !== 'gamepad';
const keyboardRef = useRef<HTMLDivElement>(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<number, number> = {};
const buttonRepeatTimeout: Record<number, NodeJS.Timeout> = {};
const buttonRepeatCounts: Record<number, number> = {};
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 <div hidden={hidden} style={{ left: '256px' }} ref={keyboardRef} className='fixed flex justify-center items-center gap-32 rounded-2xl pointer-events-none z-1000'>
{elements.map((e, i) => <div ref={sideRefs[i]} key={i} data-shift={shift} className='flex justify-center items-center size-48 rounded-full border-8 ring-4 ring-offset-48 ring-offset-base-300 ring-base-100 data-[shift=true]:ring-base-content border-base-300 backdrop-blur-2xl bg-base-100/40'>
<div ref={circleRefs[i]} className='absolute bg-base-300 rounded-full size-8'></div>
{e.elements}
<div className='text-3xl font-semibold' ref={keyIndicatorRefs[i]}></div>
</div>)}
<div className='absolute flex gap-2 mb-92'>{Object.entries(Shortcuts).map(([key, value], i) => <ShortcutPrompt key={i} id={key} icon={GamepadIconMap[value]} label={KeyElements[key] ?? key} />)}</div>
</div>;
}

View file

@ -1,13 +1,12 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; 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 { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio"; import { oneShot } from "../scripts/audio/audio";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { useEventListener } from "usehooks-ts"; import { useEventListener } from "usehooks-ts";
import { systemApi } from "../scripts/clientApi";
import { showKeyboardHandler } from "../scripts/utils";
import useActiveControl from "../scripts/gamepads"; import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function SearchInput (data: { function SearchInput (data: {
id: string; id: string;
@ -16,6 +15,7 @@ function SearchInput (data: {
compact: boolean | undefined; compact: boolean | undefined;
onInputFocus: () => void; onInputFocus: () => void;
setShowInput: (show: boolean) => void; setShowInput: (show: boolean) => void;
className?: string;
onSubmit: (search: string | undefined) => void; onSubmit: (search: string | undefined) => void;
} & FocusParams) } & FocusParams)
{ {
@ -63,9 +63,7 @@ function SearchInput (data: {
data.onSubmit?.(undefined); data.onSubmit?.(undefined);
}, inputRef as any); }, inputRef as any);
const handlInputFocus: FocusEventHandler<HTMLInputElement> = e => showKeyboardHandler(control as any, e.target); return <label ref={ref} onFocus={data.onInputFocus} className={twMerge('input rounded-full input-lg w-full max-w-xs bg-base-200 has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content', data.className)}>
return <label ref={ref} onFocus={data.onInputFocus} className='input rounded-full input-lg w-full max-w-xs has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content'>
<Search /> <Search />
<input <input
onBlur={e => onBlur={e =>
@ -74,7 +72,6 @@ function SearchInput (data: {
setLocalSearch(data.search); setLocalSearch(data.search);
}} }}
autoFocus={data.compact} autoFocus={data.compact}
onFocus={handlInputFocus}
ref={inputRef} ref={inputRef}
value={localSearch ?? ""} value={localSearch ?? ""}
onChange={v => setLocalSearch(v.target.value)} onChange={v => setLocalSearch(v.target.value)}
@ -89,6 +86,7 @@ export default function HeaderSearchField (data: {
autoSearch?: boolean; autoSearch?: boolean;
search: string | undefined, search: string | undefined,
onSubmit: (search: string | undefined) => void; onSubmit: (search: string | undefined) => void;
className?: string;
compact?: boolean; compact?: boolean;
} & FocusParams) } & FocusParams)
{ {
@ -102,7 +100,7 @@ export default function HeaderSearchField (data: {
return <div ref={ref} className='flex items-center'> return <div ref={ref} className='flex items-center'>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
{(!data.compact || showInput) && <SearchInput autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />} {(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>} {data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
</FocusContext> </FocusContext>
</div>; </div>;

View file

@ -1,6 +1,6 @@
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
@ -10,6 +10,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
{ {
const navigate = useNavigate(); const navigate = useNavigate();
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const router = useRouter();
const options: DialogEntry[] = [ const options: DialogEntry[] = [
{ {
@ -95,7 +96,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
} }
]; ];
const { dialog, setOpen, open } = useContextDialog('select-menu', { const { dialog, setOpen, open } = useContextDialog('select-menu', {
content: <ContextList showCloseButton={false} options={options} />, content: <><ContextList showCloseButton={false} options={options} /><div className="absolute left-2 right-2 top-2 text-base-content/20 text-center">{router.history.location.pathname}</div></>,
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen', className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen',
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
}); });

View file

@ -1,4 +1,4 @@
import { MouseEventHandler } from "react"; import { JSX, MouseEventHandler } from "react";
import SvgIcon, { IconType } from "./SvgIcon"; import SvgIcon, { IconType } from "./SvgIcon";
import classNames from "classnames"; import classNames from "classnames";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@ -6,8 +6,9 @@ import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: { export default function ShortcutPrompt (data: {
id: string; id: string;
icon?: IconType; icon?: IconType;
label?: string; label?: string | JSX.Element;
className?: string; className?: string;
iconClassName?: string;
onClick?: MouseEventHandler; onClick?: MouseEventHandler;
}) })
{ {
@ -23,7 +24,7 @@ export default function ShortcutPrompt (data: {
}) })
)} )}
> >
{data.icon && <SvgIcon className="size-6 portrait:size-6 md:size-8" icon={data.icon} />} {data.icon && <SvgIcon className={twMerge("size-6 portrait:size-6 md:size-8", data.iconClassName)} icon={data.icon} />}
{data.label} {data.label}
</div> </div>
); );

View file

@ -1,18 +1,14 @@
import { useContext } from 'react';
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts'; import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt'; import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon'; import { IconType } from './SvgIcon';
import { ShortcutsContext } from '../scripts/contexts';
export function FloatingShortcuts () export function FloatingShortcuts ()
{ {
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>; return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
} }
export default function Shortcuts (data: { centerElement?: any; }) export const GamepadIconMap: Record<GamePadButtonCode, IconType> = {
{
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a', [GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b', [GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x', [GamePadButtonCode.X]: 'steamdeck_button_x',
@ -32,6 +28,10 @@ export default function Shortcuts (data: { centerElement?: any; })
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
}; };
export default function Shortcuts (data: { centerElement?: any; })
{
const keyboardMap: Record<GamePadButtonCode, string> = { const keyboardMap: Record<GamePadButtonCode, string> = {
[GamePadButtonCode.A]: 'ENTER', [GamePadButtonCode.A]: 'ENTER',
[GamePadButtonCode.B]: 'ESC', [GamePadButtonCode.B]: 'ESC',
@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button} key={s.button}
id={`shortcut-${s.button}`} id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]} icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)} )}
</div> </div>
@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button} key={s.button}
id={`shortcut-${s.button}`} id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]} icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)} )}
</div> </div>

View file

@ -1,5 +1,6 @@
import "virtual:svg-icons/register"; import "virtual:svg-icons/register";
import { StaticAssetPath } from "../gen/static-icon-assets.gen"; import { StaticAssetPath } from "../gen/static-icon-assets.gen";
import { CSSProperties } from "react";
type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg` type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg`
? Rest ? Rest
@ -15,17 +16,19 @@ export default function SvgIcon ({
icon, icon,
prefix = "icon", prefix = "icon",
className, className,
style,
...props ...props
}: { }: {
icon: IconType; icon: IconType;
prefix?: string; prefix?: string;
className?: string; className?: string;
style?: CSSProperties;
}) })
{ {
const symbolId = `#${prefix}-${icon}`; const symbolId = `#${prefix}-${icon}`;
return ( return (
<svg className={className} {...props} aria-hidden="true"> <svg style={style} className={className} {...props} aria-hidden="true">
<use href={symbolId} /> <use href={symbolId} />
</svg> </svg>
); );

View file

@ -4,12 +4,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { getErrorMessage } from "react-error-boundary"; import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react"; import { Hammer, RefreshCcw, RefreshCcwDot, Settings, Trash, Trophy } from "lucide-react";
import MainActions from "./MainActions"; import MainActions from "./MainActions";
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import FocusTooltip from "../FocusTooltip"; import FocusTooltip from "../FocusTooltip";
import { useBlocker, useRouter } from "@tanstack/react-router"; import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router";
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
{ {
@ -32,6 +32,7 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
{ {
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
const navigate = useNavigate();
const fixMutation = useMutation({ const fixMutation = useMutation({
...fixSourceMutation, ...fixSourceMutation,
@ -64,7 +65,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
...deleteGameMutation({ id: data.id, source: data.source }), ...deleteGameMutation({ id: data.id, source: data.source }),
onSuccess: (d, v, r, ctx) => onSuccess: (d, v, r, ctx) =>
{ {
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => router.history.back()); ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source));
router.history.back();
}, },
onError (error) onError (error)
{ {
@ -84,9 +86,10 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
{ {
contextOptions.push({ contextOptions.push({
id: 'delete', id: 'delete',
action: () => action: (ctx) =>
{ {
deleteMutation.mutate(); deleteMutation.mutate();
ctx.close();
}, },
icon: deleteMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />, icon: deleteMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />,
content: deleteMutation.isPending ? "Deleting" : "Delete", content: deleteMutation.isPending ? "Deleting" : "Delete",
@ -98,13 +101,17 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
{ {
contextOptions.push({ contextOptions.push({
id: "fix_source", id: "fix_source",
async action (ctx) action (ctx)
{ {
if (!data.game) return; 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 }, {
ctx.close(); onSuccess (data, variables, onMutateResult, context)
{
router.navigate({ replace: true }); router.navigate({ replace: true });
}, },
});
ctx.close();
},
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />, icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
content: "Try Fix Source", content: "Try Fix Source",
type: "warning" type: "warning"
@ -126,6 +133,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
content: "Update Metadata", content: "Update Metadata",
type: "primary" 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 ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcwDot />,
content: "Update Metadata (Interactive)",
type: "primary"
});
} }
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending }); const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });

View file

@ -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 <li ref={ref} onClick={(e) => 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 ? <div>
<img className='h-32 rounded-xl' src={data.match.coverUrl}></img>
{data.selected && <span className="absolute top-4 left-4 bg-accent drop-shadow-sm text-accent-content ring-2 ring-base-100 p-1 rounded-full"><Check className="size-5" /></span>}
</div> : <div></div>}
<div className='flex flex-col gap-1'>
<div className='font-bold text-xl'>{data.match.name}</div>
<div className='text-base-content/60 max-w-lg max-h-12 overflow-hidden text-ellipsis text-wrap wrap-anywhere'>{data.match.summary}</div>
<ul className='flex flex-wrap gap-1'>
{data.showPlatform && <>
{data.match.platforms.map(p => <li className="bg-primary text-primary-content p-1 px-2 text-sm rounded-2xl">{p.name}</li>)}
<div className="divider divider-horizontal m-0"></div>
</>}
{data.match.genres.map(g => <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{g}</li>)}
{data.match.first_release_date && <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{new Date(data.match.first_release_date).toDateString()}</li>}
</ul>
</div>
</li>;
}
function SearchField (data: { setSearch: (search: string | undefined) => void; search: string | undefined; })
{
const { ref, focusKey } = useFocusable({ focusKey: `search-field-section` });
return <div ref={ref} className='flex w-full justify-center my-4'>
<FocusContext value={focusKey}>
<HeaderSearchField className="md:min-w-xl" onSubmit={v => data.setSearch(v)} search={data.search} id='search-field' />
</FocusContext>
</div>;
}
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 <div>
<SearchField setSearch={data.setSearch} search={data.search} />
<div className="divider">{isFetching ? <span className="loading loading-spinner loading-lg"></span> : <Search className='size-10' />}Results</div>
<ul className='flex flex-col gap-2 justify-center p-2 px-4'>
{lookups?.map((l, i) =>
{
return <Result key={i} selected={data.selected?.id === l.id && data.selected?.source === l.source} showPlatform={data.showPlatforms ?? false} match={l} onAction={(ctx) =>
{
data.onSelect(l);
}} />;
})}
</ul>
</div>;
}

View file

@ -10,6 +10,7 @@ import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { DownloadSourceType } from "@/shared/constants"; import { DownloadSourceType } from "@/shared/constants";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) 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 mainButton: any | undefined = undefined;
let showAllCommandsAction: ((focusKey: string) => void) | undefined;
let mainAction: () => void;
if (status === 'installed') if (status === 'installed')
{ {
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
mainAction = () => handlePlay(validDefaultCommand);
mainButton = <div className="flex gap-2"> mainButton = <div className="flex gap-2">
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} <ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
key="primary" key="primary"
type='primary' type='primary'
id="mainAction" id="mainAction"
@ -130,25 +135,26 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
</ActionButton> </ActionButton>
{validCommands.length > 1 && {showAllCommandsAction &&
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}> <ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommandsAction!('allActionsBtn')}>
<EllipsisVertical /> <EllipsisVertical />
</ActionButton>}</div>; </ActionButton>}</div>;
} }
else if (error) else if (error)
{ {
mainButton = <ActionButton mainAction = () =>
key="error"
tooltip={error}
tooltipType="error"
type='error'
onAction={() =>
{ {
if (status === 'missing-emulator') if (status === 'missing-emulator')
{ {
router.navigate({ to: '/settings/directories' }); router.navigate({ to: '/settings/directories' });
} }
}} };
mainButton = <ActionButton
key="error"
tooltip={error}
tooltipType="error"
type='error'
onAction={mainAction}
id="mainAction"> id="mainAction">
<TriangleAlert /> <TriangleAlert />
</ActionButton>; </ActionButton>;
@ -167,9 +173,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
{ {
icon = <Import />; icon = <Import />;
} }
mainButton = <ActionButton mainAction = () =>
key={status ?? 'unknown'}
onAction={() =>
{ {
if (installMut.isPending) return; if (installMut.isPending) return;
switch (status) switch (status)
@ -186,7 +190,10 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
break; break;
} }
}} };
mainButton = <ActionButton
key={status ?? 'unknown'}
onAction={mainAction}
tooltip={details ?? status} tooltip={details ?? status}
type='primary' type='primary'
id="mainAction"> id="mainAction">
@ -194,6 +201,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
</ActionButton>; </ActionButton>;
} }
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', { const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map((c, i) => content: <ContextList options={validCommands.map((c, i) =>
{ {

View file

@ -1,12 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { changeDownloadsMutation } from "@queries/settings"; 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<SettingsType, string>; })
{ {
const [localValue, setLocalValue] = useState<string | undefined>(); const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const setSettingMutation = useMutation({ const setSettingMutation = useMutation({
...changeDownloadsMutation, ...changeDownloadsMutation,
onSuccess: (d, v, r, cx) => onSuccess: (d, v, r, cx) =>
@ -25,6 +27,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
requireConfirmation={data.requireConfirmation} requireConfirmation={data.requireConfirmation}
isDirectoryPicker={true} isDirectoryPicker={true}
localValue={localValue} localValue={localValue}
defaultValue={defaultValue as any}
setLocalValue={(v) => setLocalValue={(v) =>
{ {
setLocalValue(v); setLocalValue(v);

View file

@ -1,4 +1,4 @@
import { HTMLInputTypeAttribute, JSX } from "react"; import { JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants"; import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
import { OptionSpace } from "./OptionSpace"; import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput"; import { OptionInput } from "./OptionInput";
@ -6,14 +6,9 @@ import { useLocalStorage } from "usehooks-ts";
import { OptionDropdown } from "./OptionDropdown"; import { OptionDropdown } from "./OptionDropdown";
export function LocalOption (data: { export function LocalOption (data: {
label: string;
id: keyof LocalSettingsType; id: keyof LocalSettingsType;
type: HTMLInputTypeAttribute | 'dropdown';
min?: number;
max?: number;
step?: number; step?: number;
placeholder?: string; placeholder?: string;
values?: string[];
icon?: JSX.Element; icon?: JSX.Element;
children?: any; children?: any;
}) })
@ -22,9 +17,20 @@ export function LocalOption (data: {
deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v))
}); });
const schema = LocalSettingsSchema.shape[data.id].toJSONSchema();
const typeMapping: Record<string, string> = {
string: 'text',
integer: 'range',
number: 'range',
boolean: 'checkbox'
};
return ( return (
<OptionSpace id={`${data.id}-space`} label={data.label}> <OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col gap-1">
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon} <div>{schema.title ?? data.id}</div>
<div className="text-base-content/40 text-sm">{schema.description}</div>
</div>}>
{!!schema.enum && <OptionDropdown values={schema.enum.map(v => String(v))} icon={data.icon}
name={data.id ?? ""} name={data.id ?? ""}
placeholder={data.placeholder} placeholder={data.placeholder}
defaultValue={localValue} defaultValue={localValue}
@ -33,12 +39,12 @@ export function LocalOption (data: {
setLocalValue(v); setLocalValue(v);
}} }}
value={localValue} />} value={localValue} />}
{data.type !== 'dropdown' && <OptionInput {!schema.enum && <OptionInput
icon={data.icon} icon={data.icon}
name={data.id ?? ""} name={data.id ?? ""}
type={data.type} type={schema.type ? typeMapping[schema.type] : 'text'}
min={data.min} min={schema.minimum}
max={data.max} max={schema.maximum}
step={data.step} step={data.step}
placeholder={data.placeholder} placeholder={data.placeholder}
defaultValue={localValue} defaultValue={localValue}

View file

@ -2,11 +2,9 @@ import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribu
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace"; import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { CheckIcon, X } from "lucide-react"; import { CheckIcon, X } from "lucide-react";
import { oneShot } from "@/mainview/scripts/audio/audio"; import { oneShot } from "@/mainview/scripts/audio/audio";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { showKeyboardHandler } from "@/mainview/scripts/utils";
import useActiveControl from "@/mainview/scripts/gamepads"; import useActiveControl from "@/mainview/scripts/gamepads";
export function OptionInput (data: { export function OptionInput (data: {
@ -106,7 +104,6 @@ export function OptionInput (data: {
{ {
option.focus(); option.focus();
setInputFocused(true); setInputFocused(true);
showKeyboardHandler(control as any, e.target);
}; };
const handleInputBlur = (e: any) => const handleInputBlur = (e: any) =>

View file

@ -35,6 +35,7 @@ export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
export function OptionSpace (data: { export function OptionSpace (data: {
id?: string; id?: string;
className?: string; className?: string;
innerClassName?: string;
focusable?: boolean; focusable?: boolean;
children?: any | any[]; children?: any | any[];
label?: string | JSX.Element | ((focused: boolean) => JSX.Element); label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
@ -90,7 +91,7 @@ export function OptionSpace (data: {
{!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4"> {!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4">
{labelElement} {labelElement}
</div>} </div>}
<div className="flex flex-1 justify-end-safe"> <div className={twMerge("flex flex-1 justify-end-safe", data.innerClassName)}>
{data.children} {data.children}
</div> </div>
</li> </li>

View file

@ -13,7 +13,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
export interface PathSettingsOptionParams export interface PathSettingsOptionParams
{ {
label: string; label: string;
id: KeysWithValueAssignableTo<SettingsType, string>; id: string;
type: HTMLInputTypeAttribute; type: HTMLInputTypeAttribute;
placeholder?: string; placeholder?: string;
icon?: JSX.Element; icon?: JSX.Element;
@ -24,10 +24,11 @@ export interface PathSettingsOptionParams
allowNewFolderCreation?: boolean; allowNewFolderCreation?: boolean;
} }
export function PathSettingsOption (data: PathSettingsOptionParams) export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
{ {
const [localValue, setLocalValue] = useState<string | undefined>(); const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const setMutation = useMutation({ const setMutation = useMutation({
...setSettingMutation(data.id), ...setSettingMutation(data.id),
onSuccess: (d, v, r, cx) => onSuccess: (d, v, r, cx) =>
@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
save={setMutation.mutate} save={setMutation.mutate}
localValue={localValue} localValue={localValue}
allowNewFolderCreation={data.allowNewFolderCreation} allowNewFolderCreation={data.allowNewFolderCreation}
defaultValue={defaultValue as any}
setLocalValue={(v) => setLocalValue={(v) =>
{ {
setLocalValue(v); setLocalValue(v);
@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
localValue: string | undefined; localValue: string | undefined;
setLocalValue: (value: string | undefined) => void; setLocalValue: (value: string | undefined) => void;
isDirty: boolean; isDirty: boolean;
className?: string;
defaultValue: string | undefined;
}) })
{ {
const [isBrowsing, setIsBrowsing] = useState(false); const [isBrowsing, setIsBrowsing] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const changed = data.defaultValue !== data.localValue;
const changed = defaultValue !== data.localValue;
useEffect(() => useEffect(() =>
{ {
data.setLocalValue(String(defaultValue)); data.setLocalValue(String(data.defaultValue ?? ''));
}, [defaultValue]); }, [data.defaultValue]);
const handleSelectPath = (path: string) => const handleSelectPath = (path: string) =>
{ {
@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
}; };
return ( return (
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}> <OptionSpace id={`${data.id}-space`} innerClassName="gap-2" className={data.className} label={<>{data.label}{changed && <Pen />}</>}>
<OptionInput <OptionInput
icon={data.icon} icon={data.icon}
name={`${data.id}-input`} name={`${data.id}-input`}
@ -105,7 +109,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
}} }}
value={data.localValue} value={data.localValue}
/> />
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() => <Button id={`${data.id}-browse`} className="focusable focusable-accent" onAction={() =>
{ {
setIsBrowsing(true); setIsBrowsing(true);
data.onBrowseAction?.(data.localValue); data.onBrowseAction?.(data.localValue);
@ -113,7 +117,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />} {data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
</Button> </Button>
{data.requireConfirmation === true && <Button {data.requireConfirmation === true && <Button
disabled={defaultValue === data.localValue} disabled={data.defaultValue === data.localValue}
id={`${data.id}-save`} id={`${data.id}-save`}
onAction={() => data.save(data.localValue)} onAction={() => data.save(data.localValue)}
type="button"> type="button">

View file

@ -15,10 +15,15 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
formComponents: {} formComponents: {}
}); });
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; }) function FormOption (data: {
type: HTMLInputTypeAttribute,
icon?: JSX.Element;
label?: string | JSX.Element;
placeholder?: string;
})
{ {
const field = useFieldContext<string>(); const field = useFieldContext<string>();
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2"> return <OptionSpace id={`${field.name}-space`} label={<div className="flex items-center flex-1 gap-2">
{data.label} {data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error"> {field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')} {field.state.meta.errors.map(e => e.message).join(',')}

View file

@ -7,6 +7,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
export function SettingsOption (data: { export function SettingsOption (data: {
label: string; label: string;
help?: string;
id: KeysWithValueAssignableTo<SettingsType, string | boolean>; id: KeysWithValueAssignableTo<SettingsType, string | boolean>;
type: HTMLInputTypeAttribute; type: HTMLInputTypeAttribute;
placeholder?: string; placeholder?: string;
@ -35,7 +36,10 @@ export function SettingsOption (data: {
}, [dirty, setDirty, localValue]); }, [dirty, setDirty, localValue]);
return ( return (
<OptionSpace id={`${data.id}-space`} label={data.label}> <OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col">
<div>{data.label}</div>
<div className="text-base-content/40 text-sm">{data.help}</div>
</div>}>
<OptionInput <OptionInput
icon={data.icon} icon={data.icon}
name={data.id ?? ""} name={data.id ?? ""}

View file

@ -3,9 +3,7 @@ import
useFocusable, useFocusable,
FocusContext, FocusContext,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { Button } from "../options/Button"; import { CircleQuestionMark, SearchAlert } from "lucide-react";
import useActiveControl from "@/mainview/scripts/gamepads";
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FOCUS_KEYS } from "@/mainview/scripts/types";
@ -15,14 +13,14 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
interface MissingCardProps interface MissingCardProps
{ {
emulator: FrontEndEmulator; emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void; onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
} }
function MissingCard ({ emulator: em, onSelect }: MissingCardProps) function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{ {
const handleSelect = () => const handleSelect = () =>
{ {
onSelect?.(em.name, focusKey); onSelect?.(em, focusKey);
oneShot('click'); oneShot('click');
}; };
@ -31,7 +29,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
onEnterPress: handleSelect, onEnterPress: handleSelect,
}); });
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse } = useActiveControl();
return ( return (
<div <div
@ -40,7 +37,7 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
tabIndex={0} tabIndex={0}
onClick={handleSelect} onClick={handleSelect}
onKeyDown={(e) => e.key === "Enter" && handleSelect} onKeyDown={(e) => e.key === "Enter" && handleSelect}
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"} className={"focusable focusable-accent focusable-hover cursor-pointer bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
> >
<div className="card-body p-5 gap-3"> <div className="card-body p-5 gap-3">
<div className="flex gap-4"> <div className="flex gap-4">
@ -57,10 +54,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p> <p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
</div> </div>
</div> </div>
<div className="flex items-center grow h-8">
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
</div>
</div> </div>
</div> </div>
); );
@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({
onSelect, onSelect,
}: { }: {
emulators: FrontEndEmulator[]; emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void; onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
}) })
{ {
const { ref, focusKey } = useFocusable({ const { ref, focusKey } = useFocusable({

View file

@ -19,6 +19,7 @@ import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emul
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
import { Route as SettingsAboutRouteImport } from './../routes/settings/about' import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
import { Route as GameAddRouteImport } from './../routes/game/add'
import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' 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 EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id'
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$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({ const GamesRoute = GamesRouteImport.update({
id: '/games', id: '/games',
@ -81,6 +83,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({
path: '/about', path: '/about',
getParentRoute: () => SettingsRouteRoute, getParentRoute: () => SettingsRouteRoute,
} as any) } as any)
const GameAddRoute = GameAddRouteImport.update({
id: '/game/add',
path: '/game/add',
getParentRoute: () => rootRouteImport,
} as any)
const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ const StoreTabRouteRoute = StoreTabRouteRouteImport.update({
id: '/store/tab', id: '/store/tab',
path: '/store/tab', path: '/store/tab',
@ -136,12 +143,18 @@ const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
path: '/store/details/emulator/$id', path: '/store/details/emulator/$id',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({
id: '/game/update/$source/$id',
path: '/game/update/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute '/games': typeof GamesRoute
'/store/tab': typeof StoreTabRouteRouteWithChildren '/store/tab': typeof StoreTabRouteRouteWithChildren
'/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
@ -158,12 +171,14 @@ export interface FileRoutesByFullPath {
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/': typeof StoreTabIndexRoute '/store/tab/': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute '/games': typeof GamesRoute
'/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
@ -180,6 +195,7 @@ export interface FileRoutesByTo {
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab': typeof StoreTabIndexRoute '/store/tab': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -188,6 +204,7 @@ export interface FileRoutesById {
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute '/games': typeof GamesRoute
'/store/tab': typeof StoreTabRouteRouteWithChildren '/store/tab': typeof StoreTabRouteRouteWithChildren
'/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute '/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
@ -204,6 +221,7 @@ export interface FileRoutesById {
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/': typeof StoreTabIndexRoute '/store/tab/': typeof StoreTabIndexRoute
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -213,6 +231,7 @@ export interface FileRouteTypes {
| '/settings' | '/settings'
| '/games' | '/games'
| '/store/tab' | '/store/tab'
| '/game/add'
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
@ -229,12 +248,14 @@ export interface FileRouteTypes {
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab/' | '/store/tab/'
| '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/settings' | '/settings'
| '/games' | '/games'
| '/game/add'
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
@ -251,6 +272,7 @@ export interface FileRouteTypes {
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab' | '/store/tab'
| '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
id: id:
| '__root__' | '__root__'
@ -258,6 +280,7 @@ export interface FileRouteTypes {
| '/settings' | '/settings'
| '/games' | '/games'
| '/store/tab' | '/store/tab'
| '/game/add'
| '/settings/about' | '/settings/about'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
@ -274,6 +297,7 @@ export interface FileRouteTypes {
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab/' | '/store/tab/'
| '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -282,11 +306,13 @@ export interface RootRouteChildren {
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
GamesRoute: typeof GamesRoute GamesRoute: typeof GamesRoute
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
GameAddRoute: typeof GameAddRoute
CollectionSourceIdRoute: typeof CollectionSourceIdRoute CollectionSourceIdRoute: typeof CollectionSourceIdRoute
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
GameSourceIdRoute: typeof GameSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute
LauncherSourceIdRoute: typeof LauncherSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute
PlatformSourceIdRoute: typeof PlatformSourceIdRoute PlatformSourceIdRoute: typeof PlatformSourceIdRoute
GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
} }
@ -362,6 +388,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAboutRouteImport preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof SettingsRouteRoute parentRoute: typeof SettingsRouteRoute
} }
'/game/add': {
id: '/game/add'
path: '/game/add'
fullPath: '/game/add'
preLoaderRoute: typeof GameAddRouteImport
parentRoute: typeof rootRouteImport
}
'/store/tab': { '/store/tab': {
id: '/store/tab' id: '/store/tab'
path: '/store/tab' path: '/store/tab'
@ -439,6 +472,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport
parentRoute: typeof rootRouteImport 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, SettingsRouteRoute: SettingsRouteRouteWithChildren,
GamesRoute: GamesRoute, GamesRoute: GamesRoute,
StoreTabRouteRoute: StoreTabRouteRouteWithChildren, StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
GameAddRoute: GameAddRoute,
CollectionSourceIdRoute: CollectionSourceIdRoute, CollectionSourceIdRoute: CollectionSourceIdRoute,
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
GameSourceIdRoute: GameSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute,
LauncherSourceIdRoute: LauncherSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute,
PlatformSourceIdRoute: PlatformSourceIdRoute, PlatformSourceIdRoute: PlatformSourceIdRoute,
GameUpdateSourceIdRoute: GameUpdateSourceIdRoute,
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View file

@ -24,6 +24,7 @@ import Details from "@/mainview/components/game/Details";
import { AutoFocus } from "@/mainview/components/AutoFocus"; import { AutoFocus } from "@/mainview/components/AutoFocus";
import SelectMenu from "@/mainview/components/SelectMenu"; import SelectMenu from "@/mainview/components/SelectMenu";
import { en } from "zod/v4/locales"; import { en } from "zod/v4/locales";
import { IGDBIcon } from "@/mainview/scripts/brandIcons";
export const Route = createFileRoute("/game/$source/$id")({ export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) => 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: <Calendar /> }); stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: <Calendar /> });
if (data.game.emulators) if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); 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) if (data.game.source)
stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` });
const integrations = new Set<string>(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); const integrations = new Set<string>(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c));

View file

@ -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<string | undefined>(data.location);
return <PathSettingsOptionBase
isDirty={false}
label={"Game Location"}
id={'game-location'}
type={'text'}
save={data.setLocation}
allowNewFolderCreation={false}
requireConfirmation={false}
isDirectoryPicker={false}
localValue={localLocation}
setLocalValue={setLocalLocation}
defaultValue={data.location}
/>;
}
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<string, JSX.Element> = {
new: <CirclePlus />,
existing: <HardDrive />,
unknown: <CircleQuestionMark />
};
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 <div>Select A Game</div>;
return <div className='flex flex-col items-center'>
<div className="divider">Preview</div>
<div className='flex gap-4'>
<div>{!!game[0].coverUrl && <img className='w-xs rounded-2xl' src={game[0].coverUrl}></img>}</div>
<div className='flex flex-col gap-2'>
<div className='text-3xl font-semibold'> {game[0].name}</div>
<div> {game[0].summary}</div>
<div className='flex gap-4 items-center'>
<div> {platform?.details.name}</div>
<ArrowBigRightDash className='size-10' />
<div className='flex gap-2 items-center'>
{!!platform?.match.coverUrl && <img className='size-8' src={platform?.match.coverUrl}></img>}
<div> {platform?.match.name}</div>
<div> {platform?.match.family_name}</div>
<FileSearch />
{!!platform?.match.type && typeIconMap[platform?.match.type]}
<div> {platform?.match.type}</div>
</div>
</div>
<div className='flex gap-2'><FolderOpen />{state.gameLocation}</div>
</div>
</div>
<div className="divider">Actions</div>
<div className='flex gap-2'>
<Button id="add-game-btn" style='primary' type='button' className='gap-2 focusable focusable-primary' onAction={e =>
{
if (!state.selectedGame || !state.platformId || !state.gameLocation) return;
addGame.mutate({
source: state.selectedGame.source,
id: state.selectedGame.id,
gamePath: state.gameLocation,
platformId: state.platformId
});
}} ><CirclePlus /> Add Game</Button>
<Button id="cancel-btn" style='warning' type='button' className='gap-2 focusable focusable-primary' onAction={e =>
{
HandleGoBack(router, e.event);
}} ><CircleX /> Cancel</Button>
</div>
</div>;
}
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 <OptionElement action={handleAction} className="list-row" id={data.id} content={
<div className='flex items-center gap-2'>
<div>{data.displayName}</div>
<div className="flex gap-2 divider divider-horizontal items-center"></div>
{matchIsFetching ? <span className="loading loading-spinner loading-lg"></span> : match && <>
{match.match.coverUrl ? <img className='size-8' src={match.match.coverUrl}></img> : <CircleQuestionMark />}
<div className='flex gap-2'>{match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}</div>
</>}
</div>
} 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 <span className="loading loading-spinner loading-lg"></span>;
if (!game) return <div>Select A Game</div>;
return <ul className='flex flex-col gap-2'>
{game[0].platforms.map((p, i) => <PlatformEntry key={i} displayName={p.displayName} platformSource={game[0].source} platformId={p.id} id={p.slug} />)}
</ul>;
}
function Lookup ()
{
const state = Route.useSearch();
const [search, setSearch] = useState<string | undefined>(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 <GameLookup
showPlatforms
selected={state.selectedGame}
search={search}
setSearch={setSearch}
onSelect={l =>
{
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 <div className='flex flex-col gap-4 items-stretch'>
<div className="divider"><FolderOpen className='size-12' /> Select Game Rom</div>
<FileSelectionField location={state.gameLocation ?? ''} setLocation={handleSetLocation} />
<div className='flex justify-center text-base-content/60'>
Select The Rom File from your local storage
</div>
</div>;
}
function Details (data: {})
{
const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' });
const state = Route.useSearch();
const step = state.step ?? 0;
return <div ref={ref} className='flex flex-col gap-2 p-4'>
<FocusContext value={focusKey}>
{step === 0 && <Location />}
{step === 1 && <Lookup />}
{step === 2 && <PlatformSelection />}
{step === 3 && <Overview />}
</FocusContext>
</div>;
}
function getStepDetails (index: number, state: z.infer<typeof StateSchema>)
{
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 <li ref={ref} aria-disabled={!canNavigate} onClick={e =>
{
if (!canNavigate) return;
handleGoToStep(data.index);
}} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}>
<span className="step-icon in-focused:ring-7 in-focused:ring-base-content in-aria-disabled:text-base-content/40!">{completed ? <Check /> : <CircleQuestionMark />}</span>
{data.label}
</li>;
}
function Steps ()
{
const state = Route.useSearch();
const step = state.step ?? 0;
const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false });
return <ul ref={ref} className="steps pt-2" style={{ viewTransitionName: 'steps' }}>
<FocusContext value={focusKey}>
{StepDetails.map((s, i) => <Step key={i} index={i} label={s.label} />)}
</FocusContext>
</ul>;
}
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 <div ref={ref}>
<FocusContext value={focusKey}>
<div className='absolute w-screen h-screen overflow-y-scroll'>
<StickyHeaderUI className='bg-base-300' ref={ref} />
<div className='flex justify-center mt-8'>
<Steps />
</div>
<Details />
<FloatingShortcuts />
</div>
</FocusContext>
<AutoFocus focus={focusSelf} />
{isAddingGame && <LoadingScreen>
<div className='flex gap-3'>
<span className="loading loading-spinner loading-lg"></span>
<div>Adding Game</div>
</div>
</LoadingScreen>}
</div>;
}

View file

@ -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<string | undefined>(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 <AnimatedBackground ref={ref}>
<FocusContext value={focusKey}>
<div className='flex flex-col z-10 overflow-y-scroll'>
<StickyHeaderUI ref={ref} />
<GameLookup
search={search}
setSearch={setSearch}
onSelect={l =>
update.mutate({ source, id, destination: l.source, destinationId: l.id })}
/>
<FloatingShortcuts />
<AutoFocus focus={focusSelf} />
</div>
</FocusContext>
</AnimatedBackground>;
}

View file

@ -1,12 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { CollectionsDetail } from '../components/CollectionsDetail'; import { CollectionsDetail } from '../components/CollectionsDetail';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod'; import z from 'zod';
import { GameListFilterType } from '@/shared/constants'; import { GameListFilterType } from '@/shared/constants';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import HeaderSearchField from '../components/HeaderSearchField'; import HeaderSearchField from '../components/HeaderSearchField';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; import { RoundButton } from '../components/RoundButton';
import { Plus } from 'lucide-react';
export const Route = createFileRoute('/games')({ export const Route = createFileRoute('/games')({
component: RouteComponent, component: RouteComponent,
@ -21,6 +22,7 @@ function RouteComponent ()
const { focus } = Route.useSearch(); const { focus } = Route.useSearch();
const { search } = Route.useSearch(); const { search } = Route.useSearch();
const [filter, setFilter] = useSessionStorage<GameListFilterType>('all-games-filters', {}); const [filter, setFilter] = useSessionStorage<GameListFilterType>('all-games-filters', {});
const navigate = useNavigate();
useEffect(() => useEffect(() =>
{ {
@ -28,7 +30,13 @@ function RouteComponent ()
}, [search]); }, [search]);
return <CollectionsDetail return <CollectionsDetail
headerButtonElements={<HeaderSearchField onSubmit={v => setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} headerButtonElements={
[<RoundButton external id={'add-game-btn'} onAction={(e) =>
{
navigate({ to: '/game/add' });
}} ><Plus /></RoundButton>,
<HeaderSearchField onSubmit={v => setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />]
}
localFilter={filter} localFilter={filter}
setLocalFilter={setFilter} setLocalFilter={setFilter}
focus={focus} focus={focus}

View file

@ -1,7 +1,7 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail"; import { CollectionsDetail } from "../components/CollectionsDetail";
import { useMutation, useQuery } from "@tanstack/react-query"; 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 { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm";
import { zodValidator } from "@tanstack/zod-adapter"; import { zodValidator } from "@tanstack/zod-adapter";
import z from "zod"; import z from "zod";
@ -22,7 +22,7 @@ function PlatformTitle (data: {})
const { source, id } = Route.useParams(); const { source, id } = Route.useParams();
const { data: platform } = useQuery(platformQuery(source, id)); const { data: platform } = useQuery(platformQuery(source, id));
return <div className="sm:landscape:hidden flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow"> return <div className="sm:landscape:hidden md:landscape:inline flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0"> <div className="divider mb-6 mt-0">
{!!platform && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${platform.path_cover}`} ></img>} {!!platform && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${platform.path_cover}`} ></img>}
@ -36,9 +36,10 @@ function RouteComponent ()
const { source, id } = Route.useParams(); const { source, id } = Route.useParams();
const router = useRouter(); const router = useRouter();
const { countHint } = Route.useSearch(); const { countHint } = Route.useSearch();
const { data: platform } = useQuery(platformQuery(source, id));
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {}); const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
const updatePlatform = useMutation({ const updatePlatform = useMutation({
...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context) ...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context)
{ {
context.client.invalidateQueries(localPlatformFilter(id)); context.client.invalidateQueries(localPlatformFilter(id));
}, },
@ -56,7 +57,7 @@ function RouteComponent ()
}, },
}); });
const settingsOptions: DialogEntry[] = []; const settingsOptions: DialogEntry[] = [];
if (source === 'local') if (source === 'local' || platform?.hasLocal)
{ {
settingsOptions.push({ settingsOptions.push({
id: 'update-platform', id: 'update-platform',
@ -70,7 +71,10 @@ function RouteComponent ()
router.navigate({ replace: true }); router.navigate({ replace: true });
}, },
}); });
}
if (source === 'local')
{
settingsOptions.push({ settingsOptions.push({
id: 'update-platform', id: 'update-platform',
type: "error", type: "error",
@ -97,7 +101,7 @@ function RouteComponent ()
icon: <Settings2 />, icon: <Settings2 />,
action () action ()
{ {
setPlatformSettingsOpen(true); setPlatformSettingsOpen(true, 'open-platform-settings-btn');
}, },
}]} }]}
countHint={countHint} countHint={countHint}

View file

@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button } from '../../components/options/Button'; 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 { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames'; import classNames from 'classnames';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@ -80,7 +80,10 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
}; };
return <OptionSpace id={'custom-emulator-path-option'} label={"Custom Emulator Path"}> return <OptionSpace id={'custom-emulator-path-option'} label={<div className='flex flex-col'>
<div>Custom Emulator Path</div>
<div className='text-base-content/40 text-sm'>Manually Pick a path to an emulator if not automatically found.</div>
</div>}>
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} > <Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator Emulator
<ChevronDown /> <ChevronDown />
@ -227,6 +230,18 @@ function EmulatorBadge (data: {
statusIcon = <Check />; statusIcon = <Check />;
} }
let logoUrl: string | undefined = undefined;
if (data.emulator.logo)
{
if (data.emulator.logo.startsWith('http'))
{
logoUrl = data.emulator.logo;
} else
{
logoUrl = `${RPC_URL(__HOST__)}${data.emulator.logo}`;
}
}
return <div ref={ref} className={ return <div ref={ref} className={
twMerge('grid grid-rows-3 grid-cols-1 flex-col rounded-3xl bg-base-300 items-center p-4 overflow-hidden h-full select-none focusable focusable-accent', twMerge('grid grid-rows-3 grid-cols-1 flex-col rounded-3xl bg-base-300 items-center p-4 overflow-hidden h-full select-none focusable focusable-accent',
classNames({ classNames({
@ -238,7 +253,7 @@ function EmulatorBadge (data: {
<div className='flex flex-col items-center gap-1'> <div className='flex flex-col items-center gap-1'>
<div className='flex gap-2 font-semibold'> <div className='flex gap-2 font-semibold'>
{statusIcon} {statusIcon}
{!!data.emulator.logo && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.emulator.logo}`}></img>} {!!logoUrl && <img className='size-6 drop-shadow drop-shadow-black/20' src={logoUrl}></img>}
{data.emulator.name} {data.emulator.name}
</div> </div>
<div className='text-base-content/40 max-w-full overflow-hidden text-nowrap text-ellipsis'> <div className='text-base-content/40 max-w-full overflow-hidden text-nowrap text-ellipsis'>
@ -329,7 +344,7 @@ function RouteComponent ()
<EmulatorBadges addOverride={addOverrideMutation.mutate} onFocus={scrollIntoViewHandler({ block: 'center' })} /> <EmulatorBadges addOverride={addOverrideMutation.mutate} onFocus={scrollIntoViewHandler({ block: 'center' })} />
<div className="divider text-base-content/40">Preferences</div> <div className="divider text-base-content/40">Preferences</div>
<SettingsOption label="Launch In Fullscreen" id="launchInFullscreen" type="checkbox" /> <SettingsOption label="Launch In Fullscreen" id="launchInFullscreen" type="checkbox" />
<SettingsOption label="Widescreen" id="emulatorWidescreen" type="checkbox" /> <SettingsOption label="Widescreen" help='Force games to play in wide screen. Might cause artifacts.' id="emulatorWidescreen" type="checkbox" />
<SettingsDropdown label='Resolution' id='emulatorResolution' values={SettingsSchema.shape.emulatorResolution.unwrap().options} /> <SettingsDropdown label='Resolution' id='emulatorResolution' values={SettingsSchema.shape.emulatorResolution.unwrap().options} />
<div className="divider text-base-content/40">Overrides</div> <div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} /> <NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />

View file

@ -1,4 +1,5 @@
import { LocalOption } from '@/mainview/components/options/LocalOption'; import { LocalOption } from '@/mainview/components/options/LocalOption';
import { LocalSettingsSchema, settingRegistry } from '@/shared/constants';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { Terminal } from 'lucide-react'; import { Terminal } from 'lucide-react';
@ -17,16 +18,14 @@ function RouteComponent ()
return <ul ref={ref} className="list rounded-box gap-2"> return <ul ref={ref} className="list rounded-box gap-2">
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption> {Object.keys(LocalSettingsSchema.shape)
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption> .filter(k => !settingRegistry.get(LocalSettingsSchema.shape[k as keyof typeof LocalSettingsSchema.shape])?.dev)
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption> .map(k => <LocalOption id={k as any} />)}
<LocalOption id='soundEffects' label="Sounds" type='checkbox'></LocalOption>
<LocalOption id='soundEffectsVolume' min={0} max={100} step={10} label="Sounds" type='range'></LocalOption>
<LocalOption id='hapticsEffects' label="Haptics" type='checkbox'></LocalOption>
{import.meta.env.DEV && <> {import.meta.env.DEV && <>
<div className="divider">Dev Settings<Terminal /></div> <div className="divider">Dev Settings<Terminal /></div>
<LocalOption id='showQueryDevOptions' label="Show Query Options" type='checkbox'></LocalOption> {Object.keys(LocalSettingsSchema.shape)
<LocalOption id='showRouterDevOptions' label="Show Router Options" type='checkbox'></LocalOption> .filter(k => settingRegistry.get(LocalSettingsSchema.shape[k as keyof typeof LocalSettingsSchema.shape])?.dev)
.map(k => <LocalOption id={k as any} />)}
</>} </>}
</FocusContext> </FocusContext>
</ul>; </ul>;

View file

@ -126,7 +126,7 @@ export function RouteComponent ()
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
{<Main games={featuredGames} />} {<Main games={featuredGames} />}
{!!crucialEmulators && crucialEmulators?.length > 0 && <MissingEmulatorsSection {!!crucialEmulators && crucialEmulators?.length > 0 && <MissingEmulatorsSection
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)} onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)}
emulators={crucialEmulators} />} emulators={crucialEmulators} />}
<div className='pt-4'> <div className='pt-4'>
<EmulatorsSection <EmulatorsSection

View file

@ -3,18 +3,18 @@ import { FilterUI } from '@/mainview/components/Filters';
import { HeaderUI } from '@/mainview/components/Header'; import { HeaderUI } from '@/mainview/components/Header';
import HeaderSearchField from '@/mainview/components/HeaderSearchField'; import HeaderSearchField from '@/mainview/components/HeaderSearchField';
import SelectMenu from '@/mainview/components/SelectMenu'; import SelectMenu from '@/mainview/components/SelectMenu';
import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts'; import { StoreContext } from '@/mainview/scripts/contexts';
import { gameQuery } from '@/mainview/scripts/queries/romm'; import { gameQuery } from '@/mainview/scripts/queries/romm';
import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { useMatchRoute, useRouter } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import { useRef, useState } from 'react'; import { useRef } from 'react';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import z from 'zod'; import z from 'zod';
@ -104,7 +104,7 @@ function RouteComponent ()
{ {
if (type === 'emulator') if (type === 'emulator')
{ {
if (source === 'local') return; if (!source || source === 'local') return;
router.navigate({ to: '/store/details/emulator/$id', params: { id } }); router.navigate({ to: '/store/details/emulator/$id', params: { id } });
} }
else if (type === 'game') else if (type === 'game')

View file

@ -3,7 +3,7 @@ import sounds from '../../assets/sounds.ogg';
import soundSprites from '../../assets/sounds.json'; import soundSprites from '../../assets/sounds.json';
import { getLocalSetting } from '../utils'; import { getLocalSetting } from '../utils';
import { hapticMap } from '../gamepads'; import { hapticMap } from '../gamepads';
import { soundMap } from './audioConstants'; import { soundMap, SoundMapEntry } from './audioConstants';
const timingMap = new Map<string, Date>(); const timingMap = new Map<string, Date>();
@ -47,15 +47,17 @@ function random ()
return Math.random() * 2 - 1; return Math.random() * 2 - 1;
} }
export function oneShot (id: keyof typeof soundMap) export function oneShot (id: keyof typeof soundMap, options?: { volume?: number; })
{ {
const currentDate = timingMap.get(id); const currentDate = timingMap.get(id);
if (!getLocalSetting('soundEffects')) return; if (!getLocalSetting('soundEffects')) return;
if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return; const soundValue = soundMap[id] as SoundMapEntry;
const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }; const maxDelay = soundValue.maxDelay ?? 100;
if (currentDate && new Date().getTime() - currentDate.getTime() <= maxDelay) return;
const instanceId = sound.play(soundValue.key); const instanceId = sound.play(soundValue.key);
const baseVolume = getLocalSetting("soundEffectsVolume") / 100; const baseVolume = getLocalSetting("soundEffectsVolume") / 100;
sound.volume(Math.min(baseVolume * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId); sound.volume(Math.min(baseVolume * (soundValue.volume ?? 1) * (options?.volume ?? 1) * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId);
sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId); sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId);
timingMap.set(id, new Date()); timingMap.set(id, new Date());
} }

View file

@ -3,6 +3,15 @@ import soundSprites from '../../assets/sounds.json';
const volumeVariation = 0.05; const volumeVariation = 0.05;
const rateVariation = 0.02; const rateVariation = 0.02;
export interface SoundMapEntry
{
key: keyof typeof soundSprites.sprite;
rateVariation?: number;
volumeVariation?: number;
volume?: number;
maxDelay?: number;
}
export const soundMap = { export const soundMap = {
openDetails: { key: 'Classic UI SFX - Chords #2' }, openDetails: { key: 'Classic UI SFX - Chords #2' },
returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, returnGeneric: { key: 'Classic UI SFX - Short - Low #2' },
@ -14,10 +23,16 @@ export const soundMap = {
selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation },
closeContext: { key: 'Classic UI SFX - Short - High #19' }, closeContext: { key: 'Classic UI SFX - Short - High #19' },
openContext: { key: 'Classic UI SFX - Short - High #22' }, openContext: { key: 'Classic UI SFX - Short - High #22' },
openKeyboard: { key: 'Classic UI SFX - Short - High #25' },
openStore: { key: 'Classic UI SFX - Chords #16' }, openStore: { key: 'Classic UI SFX - Chords #16' },
openSettings: { key: 'Classic UI SFX - Short - High #8' }, openSettings: { key: 'Classic UI SFX - Short - High #8' },
click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation },
clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation },
keyPress: { key: "UI_Single_Set 5_02", rateVariation, volumeVariation },
keyPressReturn: { key: "UI_Single_Set 5_04", rateVariation, volumeVariation },
keyPressSpace: { key: "UI_Single_Set 5_03", rateVariation, volumeVariation },
keyPressBackspace: { key: "UI_Single_Set 5_01", rateVariation, volumeVariation },
keyHover: { key: "UI_Single_Set 11_02", rateVariation, volumeVariation, volume: 0.5, maxDelay: 60 },
invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation },
launch: { key: "UI SFX_InGameMenu_Open" } launch: { key: "UI SFX_InGameMenu_Open" }
} satisfies Record<string, { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }>; } satisfies Record<string, SoundMapEntry>;

View file

@ -3,4 +3,8 @@ export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" /> <path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
</svg>; </svg>;
export const IGDBIcon = <svg role="img" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>IGDB</title><path d="M24 6.228c-8 .002-16 0-24 0v11.543a88.875 88.875 0 0 1 2.271-.333 74.051 74.051 0 0 1 17.038-.28c1.57.153 3.134.363 4.69.614V6.228zm-.706.707v10.013a74.747 74.747 0 0 0-22.588 0V6.934h22.588ZM7.729 8.84a2.624 2.624 0 0 0-1.857.72 2.55 2.55 0 0 0-.73 1.33c-.098.5-.063 1.03.112 1.51.177.488.515.917.954 1.196.547.354 1.224.472 1.865.401a3.242 3.242 0 0 0 1.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317 0 .634.001.951h1.105c.002.236 0 .473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464 1.464 0 0 1-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61 1.043-.647a1.8 1.8 0 0 1 1.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033 3.033 0 0 0-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093 0-.187.002-.28 0-.68.002-1.359-.004-2.038.003.003 1.666 0 3.332.002 4.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 1.156 0 0 0-.436-.73c-.264-.207-.599-.304-.93-.334a2.757 2.757 0 0 0-.279-.012Zm-16.715 0v5.002h1.102V8.927c-.368-.002-.735 0-1.102 0zm8.524 0v5.002h2.016a2.87 2.87 0 0 0 1.07-.211 2.445 2.445 0 0 0 1.174-.993c.34-.555.429-1.244.292-1.876a2.367 2.367 0 0 0-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001 1.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436 0 0 1-.22.316 1.082 1.082 0 0 1-.483.116c-.365.002-.73-.001-1.094.001-.002-.351 0-.703-.001-1.054zm-5.031.026c.28 0 .567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456 1.456 0 0 1-.335.786 1.425 1.425 0 0 1-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03 1.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" /></svg>;
export const Rclone = <svg role="img" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Rclone</title><path d="M11.842.6258C9.3647.6813 6.9754 1.9906 5.646 4.2933c-.7593 1.3144-1.0647 2.7662-.966 4.1745a7.99 7.99 0 0 1 2.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284 2.837-2.0522 4.3654-1.1695 1.5284.8824 2.0519 2.8366 1.1695 4.365l-1.4782 2.5647 1.1955 2.0714 2.3914-.0004 1.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118 13.1396.5967 11.842.6258m-1.5451 8.073-2.9605.0029C3.2844 8.7017 0 11.9867 0 16.0383c0 4.052 3.2844 7.3367 7.3364 7.3367 1.5174 0 2.9267-.4609 4.0967-1.2497a8 8 0 0 1-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765 0-3.1958-1.4307-3.1958-3.1958 0-1.7647 1.4307-3.1954 3.1958-3.1954l2.96-.0022 1.1962-2.0708zm9.587.7475a7.99 7.99 0 0 1-.935 2.5278l-.7344 1.2745c.4892.2717.915.6719 1.2153 1.192.8823 1.528.3585 3.4826-1.1699 4.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256 17.144l1.483 2.5626c2.0262 3.5091 6.513 4.7112 10.022 2.685 3.5089-2.0257 4.7112-6.5125 2.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237" /></svg>;
export const FlatpackIcon = <svg role="img" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Flathub</title><path d="M6.068 0a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 5.998-6 6 6 0 0 0-5.998-6Zm9.15.08a1.656 1.656 0 0 0-1.654 1.656v8.15a1.656 1.656 0 0 0 2.483 1.434l7.058-4.074a1.656 1.656 0 0 0 0-2.869l-1.044-.604-6.014-3.47a1.656 1.656 0 0 0-.828-.223Zm3.575 13.135a.815.815 0 0 0-.816.818v2.453h-2.454a.817.817 0 1 0 0 1.635h2.454v2.453a.817.817 0 1 0 1.635 0v-2.453h2.452a.817.817 0 1 0 0-1.635h-2.453v-2.453a.817.817 0 0 0-.818-.818zM2.865 13.5a2.794 2.794 0 0 0-2.799 2.8v4.9c0 1.55 1.248 2.8 2.8 2.8h4.9c1.55 0 2.8-1.25 2.8-2.8v-4.9c0-1.55-1.25-2.8-2.8-2.8Z" /></svg>; export const FlatpackIcon = <svg role="img" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Flathub</title><path d="M6.068 0a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 5.998-6 6 6 0 0 0-5.998-6Zm9.15.08a1.656 1.656 0 0 0-1.654 1.656v8.15a1.656 1.656 0 0 0 2.483 1.434l7.058-4.074a1.656 1.656 0 0 0 0-2.869l-1.044-.604-6.014-3.47a1.656 1.656 0 0 0-.828-.223Zm3.575 13.135a.815.815 0 0 0-.816.818v2.453h-2.454a.817.817 0 1 0 0 1.635h2.454v2.453a.817.817 0 1 0 1.635 0v-2.453h2.452a.817.817 0 1 0 0-1.635h-2.453v-2.453a.817.817 0 0 0-.818-.818zM2.865 13.5a2.794 2.794 0 0 0-2.799 2.8v4.9c0 1.55 1.248 2.8 2.8 2.8h4.9c1.55 0 2.8-1.25 2.8-2.8v-4.9c0-1.55-1.25-2.8-2.8-2.8Z" /></svg>;

View file

@ -98,6 +98,11 @@ const throttleMap = new Map<string, number>();
const throttleAcceleration = new Map<string, number>(); const throttleAcceleration = new Map<string, number>();
function throttleNav (key: string, dir: string, event: Event) function throttleNav (key: string, dir: string, event: Event)
{ {
if (document.activeElement && document.activeElement instanceof HTMLInputElement)
{
return false;
}
const minSpeed = 150; const minSpeed = 150;
const maxSpeed = 300; const maxSpeed = 300;
const currentDate = new Date(); const currentDate = new Date();

View file

@ -1,6 +1,6 @@
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
import { rommApi, settingsApi } from "../clientApi"; import { rommApi, settingsApi } from "../clientApi";
import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query";
import z from "zod"; import z from "zod";
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
@ -166,6 +166,9 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
return data; return data;
} }
}); });
export const allGamesInvalidateQuery: QueryFilters = {
queryKey: ['games']
};
export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({ export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({
predicate (query) predicate (query)
{ {
@ -192,16 +195,19 @@ export const fixSourceMutation = mutationOptions({
export const updateSourceMutation = mutationOptions({ export const updateSourceMutation = mutationOptions({
mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) =>
{ {
const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post(); const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post({
source: source,
id: id
});
if (error) throw error; if (error) throw error;
return data; return data;
} }
}); });
export const updatePlatformMutation = (id: string) => mutationOptions({ export const updatePlatformMutation = (source: string, id: string) => mutationOptions({
mutationKey: ['platform', 'local', 'update', id], mutationKey: ['platform', source, 'update', id],
mutationFn: async () => mutationFn: async () =>
{ {
const { data, error } = await rommApi.api.romm.platform.local({ id }).update.post(); const { data, error } = await rommApi.api.romm.platform({ source })({ id }).update.post();
if (error) throw error; if (error) throw error;
return data; return data;
} }
@ -230,3 +236,60 @@ export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions(
return data; return data;
} }
}); });
export const gameLookup = (search: string | undefined) => queryOptions({
queryKey: ['game', 'lookup', search],
queryFn: async () =>
{
if (!search) return [];
const { data, error } = await rommApi.api.romm.lookup.get({ query: { search } });
if (error) throw error;
return data;
}
});
export const gameLookupDetails = (source: string | undefined, id: string | undefined) => queryOptions({
enabled: !!source && !!id,
queryKey: ['game', 'lookup', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.lookup({ source: source! })({ id: id! }).get();
if (error) throw error;
return data;
}
});
export const platformLookupMatchQuery = (source: string | undefined, id: number | undefined) => queryOptions({
enabled: !!source && !!id,
queryKey: ['platform', 'lookup', 'match', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platform.lookup.match({ source: source! })({ id: id! }).get();
if (error) throw error;
return data;
}
});
export const customUpdateMutation = mutationOptions({
mutationKey: ['game', 'custom-update'], mutationFn: async (args: { source: string, id: string, destination: string, destinationId: string; }) =>
{
const { data, error } = await rommApi.api.romm.game({ source: args.source })({ id: args.id }).update.post({ source: args.destination, id: args.destinationId });
if (error) throw error;
return data;
}
});
export const addManualGameMutation = mutationOptions({
mutationKey: ['game', 'custom-add'],
mutationFn: async (args: { source: string, id: string, gamePath: string, platformId: number; }) =>
{
const { data, error } = await rommApi.api.romm.add.custom.post({
source: args.source,
id: args.id,
gamePath: args.gamePath,
platformId: args.platformId
});
if (error) throw error;
return data;
}
});

View file

@ -10,5 +10,6 @@ export const FOCUS_KEYS = {
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
GAME_SECTION: "GAME_SECTION", GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
STATS_SECTION: "STATS_SECTION", STATS_SECTION: "STATS_SECTION",
} as const; } as const;

View file

@ -372,7 +372,7 @@ export function useOnNavigateBack (callback: (state: { sound?: keyof typeof soun
}, [router]); }, [router]);
} }
export function showKeyboardHandler (activeControl: string, node?: HTMLInputElement) export function showKeyboardHandler (activeControl: string | undefined, node?: HTMLInputElement)
{ {
if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch')) if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch'))
{ {

View file

@ -1,5 +1,3 @@
import { JSX } from 'react'; import { JSX } from 'react';
import * as z from 'zod'; import * as z from 'zod';
@ -13,6 +11,9 @@ export const RPC_PORT = 8787;
export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`;
export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`;
export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`;
export const settingRegistry = z.registry<{
dev?: boolean;
}>();
export const DefaultRommStaleTime = 60 * 1000; // A minute export const DefaultRommStaleTime = 60 * 1000; // A minute
export interface GameMeta extends FocusParams export interface GameMeta extends FocusParams
@ -38,14 +39,16 @@ export const SettingsSchema = z.object({
}); });
export const LocalSettingsSchema = z.object({ export const LocalSettingsSchema = z.object({
backgroundBlur: z.stringbool().or(z.boolean()).default(true), backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }),
backgroundAnimation: z.stringbool().or(z.boolean()).default(true), backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }),
theme: z.enum(['dark', 'light', 'auto']).default('auto'), theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }),
soundEffects: z.boolean().default(true), soundEffects: z.boolean().default(true).meta({ title: "Sounds" }),
soundEffectsVolume: z.number().min(0).max(100).default(50), soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }),
hapticsEffects: z.boolean().default(true), hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }),
showRouterDevOptions: z.boolean().default(false), showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }),
showQueryDevOptions: z.boolean().default(false), showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }),
useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }),
autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" })
}); });
export const GameListFilterSchema = z.object({ export const GameListFilterSchema = z.object({
@ -114,6 +117,14 @@ export const StoreDownloadSchema = z.discriminatedUnion('type', [
}) })
]); ]);
export const NewGameSchema = z.object({
name: z.string(),
summary: z.string(),
genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, {
message: "Must be a comma-separated list",
})
});
export const StoreGameSchema = z.object({ export const StoreGameSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),

View file

@ -147,6 +147,18 @@ declare interface FrontEndId
source: string; source: string;
} }
// Stuff stored in the local sqlite metadata field
declare interface LocalGameMetadata
{
genres?: string[],
companies?: string[],
game_modes?: string[],
age_ratings?: string[];
player_count?: string;
first_release_date?: number;
average_rating?: number;
}
declare interface FrontEndPlatformType declare interface FrontEndPlatformType
{ {
id: FrontEndId; id: FrontEndId;
@ -279,6 +291,8 @@ declare interface DownloadInfo
declare interface DownloadPlatform declare interface DownloadPlatform
{ {
id: string;
source: string;
igdb_id?: number; igdb_id?: number;
igdb_slug?: string; igdb_slug?: string;
ra_id?: number; ra_id?: number;
@ -329,6 +343,32 @@ declare interface EmulatorSupport
capabilities?: EmulatorCapabilities[]; capabilities?: EmulatorCapabilities[];
} }
declare interface GameLookup
{
source: string;
id: string;
coverUrl: string | null | undefined;
slug: string | null | undefined;
screenshotUrls: string[];
name: string;
summary: string | null | undefined;
genres: string[];
companies: string[];
game_modes: string[];
age_ratings: string[];
player_count: string | undefined;
first_release_date: number | undefined;
average_rating: number | undefined;
keywords: string[];
igdb_id: number | undefined;
platforms: {
id: number;
name?: string | null;
displayName: string;
slug: string;
}[];
}
declare interface AutoSaveChange declare interface AutoSaveChange
{ {
subPath: string; subPath: string;

BIN
src/sounds/UI_Single_Set 5_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 5_03.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 5_04.wav (Stored with Git LFS) Normal file

Binary file not shown.