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

@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
@ -21,6 +21,7 @@ import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -491,6 +492,24 @@ export default new Elysia()
{
return update(source, id);
})
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
{
return customUpdate(source, id, body.source, body.id);
}, { body: z.object({ source: z.string(), id: z.string() }) })
.get('/lookup', async ({ query: { search } }) =>
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ search, matches });
return matches;
}, {
query: z.object({ search: z.string() })
})
.get('/lookup/:source/:id', async ({ params: { source, id } }) =>
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source, id, matches });
return matches;
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
@ -651,4 +670,17 @@ export default new Elysia()
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
})
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
{
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true);
return { source: 'local', id: data.localId };
}, {
body: z.object({
source: z.string(),
id: z.string(),
gamePath: z.string(),
platformId: z.number()
})
});

View file

@ -1,8 +1,9 @@
import Elysia, { status } from "elysia";
import z from "zod";
import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm";
import { db, plugins } from "../app";
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
import { config, db, plugins } from "../app";
import * as schema from "@schema/app";
import { findPlatform } from "./services/utils";
export default new Elysia()
.get('/platforms', async () =>
@ -91,7 +92,8 @@ export default new Elysia()
{
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (!remotePlatform) return status("Not Found");
return remotePlatform;
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
return { ...remotePlatform, hasLocal: !!local };
}
}, { params: z.object({ source: z.string(), id: z.string() }) })
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
@ -114,15 +116,31 @@ export default new Elysia()
}
return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<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");
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: localPlatform.slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`);
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
@ -144,4 +162,23 @@ export default new Elysia()
.where(eq(schema.games.platform_id, Number(id)))
))).returning();
if (deleted.length <= 0) return status("Not Found");
})
.get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) =>
{
const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id });
if (!platformLookup) return status("Not Found");
const match = await findPlatform({
system_slug: platformLookup.slug,
platform: {
source_slug: platformLookup.slug,
source_id: Number(id),
source: source,
name: platformLookup.name
}
});
return { details: platformLookup, match };
}, {
detail: {
description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found."
}
});

View file

@ -4,7 +4,7 @@ import { getErrorMessage } from "@/bun/utils";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises';
import Elysia from "elysia";
import z, { string } from "zod";
import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
@ -41,6 +41,63 @@ export async function getLocalGame (source: string, id: string)
return localGame;
}
/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */
export async function customUpdate (source: string, id: string, destination: string, destinationId: string)
{
const localGame = await getLocalGame(source, id);
if (!localGame) throw new Error("Could not find Local Game");
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: destination, id: destinationId, matches });
if (matches.length <= 0) throw new Error("Could not find destination");
const match = matches[0];
await db.transaction(async (tx) =>
{
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
// pre-fetch screenshots
const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof appSchema.screenshots.$inferInsert = {
game_id: localGame.id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
let cover: Buffer<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)
{
const localGame = await getLocalGame(source, id);
@ -56,10 +113,11 @@ export async function update (source: string, id: string)
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) });
if (igdbLookup)
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches });
if (matches.length > 0)
{
paths_screenshots.push(...igdbLookup.screenshotUrls);
paths_screenshots.push(...matches[0].screenshotUrls);
}
}

View file

@ -2,23 +2,25 @@ import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config, db, emulatorsDb, plugins } from "../../app";
import { and, eq } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import * as schema from "@schema/app";
import { RPC_URL, StoreGameType } from "@shared/constants";
import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import secrets from "../../secrets";
import * as emulatorSchema from "@schema/emulators";
export async function calculateSize (installPath: string | null)
{
if (!installPath) return null;
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return (await getFolderSize(finalPath)).size;
}
export async function checkInstalled (installPath: string | null)
{
if (!installPath) return false;
return fs.exists(path.join(config.get('downloadPath'), installPath));
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return fs.exists(finalPath);
}
export function getScreenshotLocalGameMatch (id: string, source: string)
@ -171,4 +173,297 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
}
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
}));
}
export async function findPlatform (info: {
system_slug: string; platform: {
igdb_id?: number;
igdb_slug?: string;
ra_id?: number;
moby_id?: number;
source: string;
source_id?: number;
source_slug?: string;
family_name?: string;
name?: string;
} | undefined;
}):
Promise<{
type: string | null;
slug?: string | null;
name?: string | null;
family_name?: string | null;
es_slug?: string | null;
coverUrl?: string | null;
}>
{
// Search for existing platform
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
if (info.platform)
{
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
if (info.platform.source_slug)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
} else if (info.platform.source_id)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
} else
{
throw new Error("Must Provide at least one source id or slug");
}
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) });
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.source_slug ?? info.system_slug
});
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" });
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" });
}
if (!esPlatform && !info.platform)
{
// go to unknown platform
existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
return {
type: "existing",
slug: existingPlatform.slug,
name: existingPlatform.name,
family_name: existingPlatform.family_name,
es_slug: existingPlatform.es_slug,
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
};
} else
{
return { type: "unknown" };
}
} else
{
return {
type: "new",
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined,
coverUrl: platformCover.url
};
}
} else
{
return {
type: "existing",
slug: existingPlatform.slug,
name: existingPlatform.name,
family_name: existingPlatform.family_name,
es_slug: existingPlatform.es_slug,
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
};
}
}
export async function createLocalGame (info: {
name: string;
system_slug: string | undefined;
source: string | undefined;
source_id: string | undefined;
slug: string | null | undefined;
path_fs: string | null | undefined;
summary: string | null | undefined;
igdb_id: number | undefined;
ra_id: number | undefined;
main_glob: string | undefined;
cover: Buffer<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 { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook } from 'tapable';
export class GameHooks
{
@ -95,7 +95,12 @@ export class GameHooks
name?: string;
family_name?: string;
} | undefined>(['ctx']);
gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']);
gameLookup = new AsyncSeriesHook<[ctx: {
source?: string,
id?: string;
search?: string;
matches: GameLookup[];
}]>(['ctx']);
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);

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 { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises';
import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path, { join } from 'node:path';
import { config, db, emulatorsDb, events, plugins } from "../app";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import path from 'node:path';
import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod";
import { checkFiles } from "../games/services/utils";
import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
@ -37,6 +32,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
// The local game ID of newly created entry, if successful
public localGameId?: number;
public group = InstallJob.id;
public localPath?: string;
constructor(id: string, source: string, config?: JobConfig)
{
@ -51,18 +47,19 @@ export class InstallJob implements IJob<never, InstallJobStates>
await fs.mkdir(config.get('downloadPath'), { recursive: true });
const downloadPath = config.get('downloadPath');
let info: DownloadInfo | undefined;
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
const finalFiles: string[] = [];
let info: DownloadInfo | undefined;
if (this.config?.dryRun !== true)
{
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{
const headers: Record<string, string> = {};
@ -197,143 +194,32 @@ export class InstallJob implements IJob<never, InstallJobStates>
if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
if (info.platform)
{
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug));
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.slug ?? info.system_slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
if (!esPlatform && !info.platform)
{
// go to unknown platform
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
platformId = existingPlatform.id;
} else
{
const [{ id }] = await tx.insert(schema.platforms).values({
slug: 'unknown',
name: "Unknown"
}).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
// Create new local platform
const platform: typeof schema.platforms.$inferInsert = {
slug: info.platform?.slug ?? esPlatform?.system.name ?? '',
igdb_id: info.platform?.igdb_id,
igdb_slug: info.platform?.igdb_slug,
ra_id: info.platform?.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined,
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
platformId = existingPlatform.id;
}
// create the rom
const game: typeof schema.games.$inferInsert = {
source_id: info.source_id,
source: this.source,
slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
last_played: info.last_played,
platform_id: platformId,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
summary: info.summary,
name: info.name,
cover,
cover_type: coverResponse.headers.get('content-type'),
metadata: info.metadata,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
version_system: info.version_system
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (info.screenshotUrls.length <= 0 && info.igdb_id)
{
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) });
if (igdbLookup) return igdbLookup.screenshotUrls;
return [];
}
// pre-fetch screenshots
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
this.localGameId = id;
this.localGameId = await createLocalGame({
cover,
coverType: coverResponse.headers.get('content-type'),
system_slug: info.system_slug,
source_id: info.source_id,
source: this.source,
slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
summary: info.summary,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
name: info.name,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
screenshotUrls: info.screenshotUrls,
version_system: info.version_system,
metadata: info.metadata,
platform: info.platform
});
if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
} else
{
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
}
await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
}
}

View file

@ -288,7 +288,7 @@ export default class IgdbIntegration implements PluginType
}
const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath);
const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath);
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
@ -449,7 +449,7 @@ export default class IgdbIntegration implements PluginType
}
const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs);
const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs);
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
});

View file

@ -4,7 +4,7 @@
"version": "0.0.1",
"description": "Rclone integration for syncing saves",
"main": "./rclone.ts",
"icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E",
"category": "saves",
"keywords": [
"integration",

View file

@ -42,17 +42,53 @@ export default class IgdbIntegration implements PluginType
{
await checkLoginAndRefreshTwitch();
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) =>
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id, search, matches }) =>
{
if (!process.env.TWITCH_CLIENT_ID) return;
if (source !== 'igdb') return;
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
if (!access_token)
{
return;
}
if ((source === 'igdb' && id) || search)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute();
return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) };
const { data: games } = await this.queue.add(() => client.request('games')
.pipe(...(search ? [igdb.search(search)] : []),
igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']),
...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []),
igdb.limit(10)).execute());
matches.push(...games.filter(g => !!g.name)
.map(g =>
{
const lookup: GameLookup = {
source: 'igdb',
id: String(g.id),
coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined,
screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [],
name: g.name!,
summary: g.summary,
genres: g.genres?.map(g => g.name!) ?? [],
companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [],
game_modes: g.game_modes?.map(m => m.name!) ?? [],
age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [],
player_count: undefined,
// UNIX date, needs to be converted
first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined,
average_rating: g.rating ?? undefined,
keywords: g.keywords?.map(k => k.name!) ?? [],
igdb_id: g.id,
platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [],
slug: g.slug
};
return lookup;
}));
return;
}
});

View file

@ -4,7 +4,7 @@
"version": "0.0.1",
"description": "IGDB Metadata Integration",
"main": "./igdb.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E",
"category": "sources",
"keywords": [
"integration",

View file

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

View file

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

View file

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

View file

@ -57,15 +57,6 @@ export async function getRelevantEmulators ()
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
const integrations = findEmulatorPluginIntegration(emulator, execPaths);
const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator });
if (storeEmulator)
{
storeEmulator.validSources = execPaths;
storeEmulator.integrations = integrations;
return storeEmulator;
}
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
if (validSystemSlug?.system)
@ -78,7 +69,17 @@ export async function getRelevantEmulators ()
systems.forEach(s => platformViability.set(s, true));
}
const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator });
if (storeEmulator)
{
storeEmulator.validSources = execPaths;
storeEmulator.integrations = integrations;
return { ...storeEmulator, isCritical: false };
}
const em: FrontEndEmulator & { isCritical: boolean; } = {
source: 'local',
name: emulator,
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
@ -92,6 +93,7 @@ export async function getRelevantEmulators ()
}));
finalEmulators.push({
source: 'local',
name: 'EMULATORJS',
validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,

View file

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

View file

@ -1,8 +1,5 @@
import { and } from 'drizzle-orm';
import EventEmitter from 'node:events';
import z, { any } from 'zod';
import z from 'zod';
export class TaskQueue
{
@ -10,7 +7,16 @@ export class TaskQueue
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
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>
: never
{
@ -35,7 +41,7 @@ export class TaskQueue
{
job.job.start();
this.activeQueue.push(job.job);
job.job.promise.promise.finally(() =>
job.job.promise.promise.catch(e => { }).finally(() =>
{
const index = this.activeQueue.indexOf(job.job);
this.activeQueue.splice(index, 1);
@ -235,26 +241,21 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
}
} catch (error)
{
try
if (error instanceof Event)
{
if (error instanceof Event)
if (error.target instanceof AbortSignal)
{
if (error.target instanceof AbortSignal)
{
} else
{
console.error(error);
}
this.m_promise.resolve(undefined);
} else
{
console.error(error);
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
this.m_promise.reject(error);
}
} finally
} else
{
this.m_promise.resolve(undefined);
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
this.m_promise.reject(error);
}
} finally

View file

@ -36,28 +36,52 @@
34000,
2489.5918367346967
],
"Classic UI SFX - Chords #16": [
"Classic UI SFX - Short - High #25": [
38000,
2005.215419501134
],
"Classic UI SFX - Chords #16": [
42000,
4005.215419501134
],
"Classic UI SFX - Short - High #8": [
44000,
48000,
2916.6666666666642
],
"UI_Single_Set 16_03": [
48000,
52000,
309.5918367346968
],
"UI_Single_Set 16_01": [
50000,
54000,
309.5918367346968
],
"UI_Single_Set 5_02": [
56000,
875.0113378684787
],
"UI_Single_Set 5_04": [
58000,
531.247165532882
],
"UI_Single_Set 5_03": [
60000,
531.247165532882
],
"UI_Single_Set 5_01": [
62000,
875.0113378684787
],
"UI_Single_Set 11_02": [
64000,
93.74149659863917
],
"Classic UI SFX - Short - Low #6": [
52000,
2333.3333333333358
66000,
2333.3333333333285
],
"UI SFX_InGameMenu_Open": [
56000,
70000,
2614.104308390026
]
}

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

View file

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

View file

@ -1,24 +1,17 @@
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderButton, StickyHeaderUI } from './Header';
import { GameList } from './GameList';
import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react';
import { JSX, Suspense, useRef, useState } from 'react';
import { JSX, Suspense } from 'react';
import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
import { GameListFilterType } from '@/shared/constants';
import { HandleGoBack } from '../scripts/utils';
import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { useRouter } from '@tanstack/react-router';
import SelectMenu from './SelectMenu';
import { RoundButton } from './RoundButton';
import { ContextList, DialogEntry, useContextDialog } from './ContextDialog';
import classNames from 'classnames';
import { sourceIconMap } from './Constants';
import { stat } from 'fs-extra';
import { FilterUI } from './Filters';
import SideFilters from './SideFilters';
export interface CollectionsDetailParams
@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<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-dots'></div>
{finalFilter && data.title}
{!!finalFilter && data.title}
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
<GameList
key={`${data.id}-${JSON.stringify(finalFilter)}`}

View file

@ -18,7 +18,7 @@ export function ContextList (data: {
{
const context = useContext(ContextDialogContext);
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 && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
};
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
onEnterPress: data.shortcuts ? undefined : handleAction,
onEnterPress: handleAction,
onFocus: handleFocus,
trackChildren: typeof data.content !== 'string'
});

View file

@ -1,9 +1,8 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { FocusEventHandler, useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType } from "@/shared/constants";
import classNames from "classnames";
@ -15,7 +14,6 @@ import toast from "react-hot-toast";
import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads";
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
import { showKeyboardHandler } from "../scripts/utils";
function List (data: {
id: string,
@ -48,7 +46,7 @@ function List (data: {
let icon = <Folder className="text-warning" />;
if (isDefaultPath)
{
icon = <FolderInput className="text-warning" />;
icon = f.isDirectory ? <FolderInput className="text-accent" /> : <FileInput className="text-accent" />;
} else if (!f.isDirectory)
{
icon = <File />;
@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName:
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) =>
{
focusSelf();
showKeyboardHandler(control as any, e.target);
};
return <div className={data.className} ref={ref}>
<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 { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
import { Search } from "lucide-react";
import { RoundButton } from "./RoundButton";
import { useEventListener } from "usehooks-ts";
import { systemApi } from "../scripts/clientApi";
import { showKeyboardHandler } from "../scripts/utils";
import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function SearchInput (data: {
id: string;
@ -16,6 +15,7 @@ function SearchInput (data: {
compact: boolean | undefined;
onInputFocus: () => void;
setShowInput: (show: boolean) => void;
className?: string;
onSubmit: (search: string | undefined) => void;
} & FocusParams)
{
@ -63,9 +63,7 @@ function SearchInput (data: {
data.onSubmit?.(undefined);
}, inputRef as any);
const handlInputFocus: FocusEventHandler<HTMLInputElement> = e => showKeyboardHandler(control as any, e.target);
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'>
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)}>
<Search />
<input
onBlur={e =>
@ -74,7 +72,6 @@ function SearchInput (data: {
setLocalSearch(data.search);
}}
autoFocus={data.compact}
onFocus={handlInputFocus}
ref={inputRef}
value={localSearch ?? ""}
onChange={v => setLocalSearch(v.target.value)}
@ -89,6 +86,7 @@ export default function HeaderSearchField (data: {
autoSearch?: boolean;
search: string | undefined,
onSubmit: (search: string | undefined) => void;
className?: string;
compact?: boolean;
} & FocusParams)
{
@ -102,7 +100,7 @@ export default function HeaderSearchField (data: {
return <div ref={ref} className='flex items-center'>
<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>}
</FocusContext>
</div>;

View file

@ -1,6 +1,6 @@
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
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 { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
@ -10,6 +10,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
{
const navigate = useNavigate();
const matchRoute = useMatchRoute();
const router = useRouter();
const options: DialogEntry[] = [
{
@ -95,7 +96,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
}
];
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',
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 classNames from "classnames";
import { twMerge } from "tailwind-merge";
@ -6,8 +6,9 @@ import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: {
id: string;
icon?: IconType;
label?: string;
label?: string | JSX.Element;
className?: string;
iconClassName?: string;
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}
</div>
);

View file

@ -1,36 +1,36 @@
import { useContext } from 'react';
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts';
import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
import { ShortcutsContext } from '../scripts/contexts';
export function FloatingShortcuts ()
{
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
}
export const GamepadIconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
export default function Shortcuts (data: { centerElement?: any; })
{
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
const keyboardMap: Record<GamePadButtonCode, string> = {
[GamePadButtonCode.A]: 'ENTER',
@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,10 +15,15 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
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>();
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}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{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: {
label: string;
help?: string;
id: KeysWithValueAssignableTo<SettingsType, string | boolean>;
type: HTMLInputTypeAttribute;
placeholder?: string;
@ -35,7 +36,10 @@ export function SettingsOption (data: {
}, [dirty, setDirty, localValue]);
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
icon={data.icon}
name={data.id ?? ""}

View file

@ -3,9 +3,7 @@ import
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads";
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
import { CircleQuestionMark, SearchAlert } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
@ -15,14 +13,14 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
interface MissingCardProps
{
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
}
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{
const handleSelect = () =>
{
onSelect?.(em.name, focusKey);
onSelect?.(em, focusKey);
oneShot('click');
};
@ -31,7 +29,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
onEnterPress: handleSelect,
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse } = useActiveControl();
return (
<div
@ -40,7 +37,7 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
tabIndex={0}
onClick={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="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>
</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>
);
@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({
onSelect,
}: {
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
})
{
const { ref, focusKey } = useFocusable({

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

View file

@ -24,6 +24,7 @@ import Details from "@/mainview/components/game/Details";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import SelectMenu from "@/mainview/components/SelectMenu";
import { en } from "zod/v4/locales";
import { IGDBIcon } from "@/mainview/scripts/brandIcons";
export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) =>
@ -105,6 +106,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: <Calendar /> });
if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
if (data.game.igdb_id)
stats.push({ label: "IGDB", icon: IGDBIcon, content: String(data.game.igdb_id) });
if (data.game.source)
stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` });
const integrations = new Set<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 { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { GameListFilterType } from '@/shared/constants';
import { useSessionStorage } from 'usehooks-ts';
import HeaderSearchField from '../components/HeaderSearchField';
import { useEffect, useState } from 'react';
import { setFocus } from '@noriginmedia/norigin-spatial-navigation';
import { useEffect } from 'react';
import { RoundButton } from '../components/RoundButton';
import { Plus } from 'lucide-react';
export const Route = createFileRoute('/games')({
component: RouteComponent,
@ -21,6 +22,7 @@ function RouteComponent ()
const { focus } = Route.useSearch();
const { search } = Route.useSearch();
const [filter, setFilter] = useSessionStorage<GameListFilterType>('all-games-filters', {});
const navigate = useNavigate();
useEffect(() =>
{
@ -28,7 +30,13 @@ function RouteComponent ()
}, [search]);
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}
setLocalFilter={setFilter}
focus={focus}

View file

@ -1,7 +1,7 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useMutation, useQuery } from "@tanstack/react-query";
import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
import { GameListFilterType, RPC_URL } from "../../shared/constants";
import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm";
import { zodValidator } from "@tanstack/zod-adapter";
import z from "zod";
@ -22,7 +22,7 @@ function PlatformTitle (data: {})
const { source, id } = Route.useParams();
const { data: platform } = useQuery(platformQuery(source, id));
return <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">
{!!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 router = useRouter();
const { countHint } = Route.useSearch();
const { data: platform } = useQuery(platformQuery(source, id));
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
const updatePlatform = useMutation({
...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context)
...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context)
{
context.client.invalidateQueries(localPlatformFilter(id));
},
@ -56,7 +57,7 @@ function RouteComponent ()
},
});
const settingsOptions: DialogEntry[] = [];
if (source === 'local')
if (source === 'local' || platform?.hasLocal)
{
settingsOptions.push({
id: 'update-platform',
@ -70,7 +71,10 @@ function RouteComponent ()
router.navigate({ replace: true });
},
});
}
if (source === 'local')
{
settingsOptions.push({
id: 'update-platform',
type: "error",
@ -97,7 +101,7 @@ function RouteComponent ()
icon: <Settings2 />,
action ()
{
setPlatformSettingsOpen(true);
setPlatformSettingsOpen(true, 'open-platform-settings-btn');
},
}]}
countHint={countHint}

View file

@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '../../components/options/Button';
import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
@ -80,7 +80,10 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
};
return <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)} >
Emulator
<ChevronDown />
@ -227,6 +230,18 @@ function EmulatorBadge (data: {
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={
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({
@ -238,7 +253,7 @@ function EmulatorBadge (data: {
<div className='flex flex-col items-center gap-1'>
<div className='flex gap-2 font-semibold'>
{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}
</div>
<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' })} />
<div className="divider text-base-content/40">Preferences</div>
<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} />
<div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />

View file

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

View file

@ -126,7 +126,7 @@ export function RouteComponent ()
<FocusContext value={focusKey}>
{<Main games={featuredGames} />}
{!!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} />}
<div className='pt-4'>
<EmulatorsSection

View file

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

View file

@ -3,7 +3,7 @@ import sounds from '../../assets/sounds.ogg';
import soundSprites from '../../assets/sounds.json';
import { getLocalSetting } from '../utils';
import { hapticMap } from '../gamepads';
import { soundMap } from './audioConstants';
import { soundMap, SoundMapEntry } from './audioConstants';
const timingMap = new Map<string, Date>();
@ -47,15 +47,17 @@ function random ()
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);
if (!getLocalSetting('soundEffects')) return;
if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return;
const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; };
const soundValue = soundMap[id] as SoundMapEntry;
const maxDelay = soundValue.maxDelay ?? 100;
if (currentDate && new Date().getTime() - currentDate.getTime() <= maxDelay) return;
const instanceId = sound.play(soundValue.key);
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);
timingMap.set(id, new Date());
}

View file

@ -3,6 +3,15 @@ import soundSprites from '../../assets/sounds.json';
const volumeVariation = 0.05;
const rateVariation = 0.02;
export interface SoundMapEntry
{
key: keyof typeof soundSprites.sprite;
rateVariation?: number;
volumeVariation?: number;
volume?: number;
maxDelay?: number;
}
export const soundMap = {
openDetails: { key: 'Classic UI SFX - Chords #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 },
closeContext: { key: 'Classic UI SFX - Short - High #19' },
openContext: { key: 'Classic UI SFX - Short - High #22' },
openKeyboard: { key: 'Classic UI SFX - Short - High #25' },
openStore: { key: 'Classic UI SFX - Chords #16' },
openSettings: { key: 'Classic UI SFX - Short - High #8' },
click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation },
clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation },
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 },
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" />
</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>;

View file

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

View file

@ -1,6 +1,6 @@
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
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 { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
@ -166,6 +166,9 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
return data;
}
});
export const allGamesInvalidateQuery: QueryFilters = {
queryKey: ['games']
};
export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({
predicate (query)
{
@ -192,16 +195,19 @@ export const fixSourceMutation = mutationOptions({
export const updateSourceMutation = mutationOptions({
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;
return data;
}
});
export const updatePlatformMutation = (id: string) => mutationOptions({
mutationKey: ['platform', 'local', 'update', id],
export const updatePlatformMutation = (source: string, id: string) => mutationOptions({
mutationKey: ['platform', source, 'update', id],
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;
return data;
}
@ -229,4 +235,61 @@ export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions(
if (error) throw error;
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}`,
GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
STATS_SECTION: "STATS_SECTION",
} as const;

View file

@ -372,7 +372,7 @@ export function useOnNavigateBack (callback: (state: { sound?: keyof typeof soun
}, [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'))
{

View file

@ -1,5 +1,3 @@
import { JSX } from 'react';
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 EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_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 interface GameMeta extends FocusParams
@ -38,14 +39,16 @@ export const SettingsSchema = z.object({
});
export const LocalSettingsSchema = z.object({
backgroundBlur: z.stringbool().or(z.boolean()).default(true),
backgroundAnimation: z.stringbool().or(z.boolean()).default(true),
theme: z.enum(['dark', 'light', 'auto']).default('auto'),
soundEffects: z.boolean().default(true),
soundEffectsVolume: z.number().min(0).max(100).default(50),
hapticsEffects: z.boolean().default(true),
showRouterDevOptions: z.boolean().default(false),
showQueryDevOptions: z.boolean().default(false),
backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }),
backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }),
theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }),
soundEffects: z.boolean().default(true).meta({ title: "Sounds" }),
soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }),
hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }),
showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }),
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({
@ -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({
name: z.string(),
description: z.string(),

View file

@ -147,6 +147,18 @@ declare interface FrontEndId
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
{
id: FrontEndId;
@ -279,6 +291,8 @@ declare interface DownloadInfo
declare interface DownloadPlatform
{
id: string;
source: string;
igdb_id?: number;
igdb_slug?: string;
ra_id?: number;
@ -329,6 +343,32 @@ declare interface EmulatorSupport
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
{
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.