feat: Implemented emulator installation

feat: Updated romm API version
feat: Updated es-de rules
feat: Added tabs to game details
refactor: returned to global query definitions to help with typescript performance
This commit is contained in:
Simeon Radivoev 2026-03-22 01:11:21 +02:00
parent cf6fff6fac
commit 3750e9ed8f
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
103 changed files with 4888 additions and 1632 deletions

View file

@ -5,6 +5,7 @@
"": {
"name": "electrobun-hello-world",
"dependencies": {
"7zip-min": "^3.0.1",
"@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6",
@ -83,6 +84,10 @@
},
},
"packages": {
"7zip-bin": ["7zip-bin@5.1.1", "", {}, "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ=="],
"7zip-min": ["7zip-min@3.0.1", "", { "dependencies": { "7zip-bin": "5.1.1" } }, "sha512-WB4VCA/KSKzxhj+BAp8fI3ZYMMAftclkXlUTckuiDacsqyubQxxG3lGcpBcgzWWuJqnfQncEq1xrJpPLSxqsxw=="],
"@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="],
"@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="],

View file

@ -13,9 +13,9 @@
"packageManager": "bun@1.3.9",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun run build:vite && bun run ./scripts/dev.ts",
"dev": " NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
"build:vite": "vite build",
"build:vite": "bun run --bun vite build",
"build:prod:vite": "NODE_ENV=production bun run build:vite",
"build:dev:vite": "NODE_ENV=development bun run build:vite",
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
@ -24,7 +24,7 @@
"build:linux": "TARGET=bun-linux-x64 bun run build",
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
"hmr": "vite --port 5173",
"hmr": "bun run --bun vite --port 5173",
"drizzle:generate": "bunx drizzle-kit generate",
"test": "bun test",
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts",
@ -40,6 +40,7 @@
"package:Windows": "bun run build:prod"
},
"dependencies": {
"7zip-min": "^3.0.1",
"@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6",

View file

@ -1,4 +1,3 @@
// watcher.ts - run this instead of --watch
import EventEmitter from "events";
import browser from '../src/bun/browser';
import { tmpdir } from "os";
@ -13,9 +12,9 @@ let retries = 0;
function spawnServer ()
{
return Bun.spawn(["bun", "run", '--watch', "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
return Bun.spawn(["bun", '--watch', '--install=fallback', '--smol', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
env: {
...Bun.env,
...process.env,
HEADLESS: "true",
},
stdout: "inherit",
@ -50,7 +49,7 @@ function spawnBrowser ()
try
{
return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
return browser(events, process.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
} catch (error)
{
console.error(error);

View file

@ -6,8 +6,6 @@ import { Database } from "bun:sqlite";
import * as schema from '../src/bun/api/schema/emulators';
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import path from 'node:path';
import { ensureDir } from 'fs-extra';
/** get all latest supported romm platforms */
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
@ -57,6 +55,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
const emulators = $r('ruleList emulator').toArray().map(s =>
{
const $emulator = $r(s);
const comment = $emulator.contents().toArray().find((node) => node.type === 'comment');
const $systempath = $emulator.find('rule[type=systempath] entry');
const $staticpath = $emulator.find('rule[type=staticpath] entry');
const $corepath = $emulator.find('rule[type=corepath] entry');
@ -66,12 +65,14 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
const emulatorName = $emulator.attr('name');
const emulator: typeof schema.emulators.$inferInsert = {
name: emulatorName!,
fullname: comment?.data.trim(),
systempath: $systempath.toArray().map(p => $r(p).text()),
staticpath: $staticpath.toArray().map(p => $r(p).text()),
corepath: $corepath.toArray().map(p => $r(p).text()),
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
};
return emulator;
});
@ -143,6 +144,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
commands,
mappings
};
return system;
}));

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,7 @@ import { appPath, getErrorMessage } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra";
import UpdateStoreJob from "./jobs/update-store";
import { getStoreFolder } from "./store/services/gamesService";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
@ -47,6 +48,8 @@ export const customEmulators = new Conf<Record<string, string>>({
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
console.log("App Directory is ", process.env.APPDIR);
console.log("Store Directory is ", getStoreFolder());
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);

View file

@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { cache } from "./app";
import cacheSchema from "@schema/cache";
import { GithubReleaseSchema } from "@/shared/constants";
export const CACHE_KEYS = {
ROM_PLATFORMS: 'rom-platforms',
@ -32,3 +33,13 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
return data;
}
export async function getOrCachedGithubRelease (path: string)
{
return getOrCached(`github-release-${path}`, async () =>
{
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
if (!response.ok) throw new Error(response.statusText);
return GithubReleaseSchema.parseAsync(await response.json());
});
}

View file

@ -1,22 +1,27 @@
import Elysia, { status } from "elysia";
import { activeGame, config, db, events, taskQueue } from "../app";
import { and, eq, getTableColumns, sql } from "drizzle-orm";
import z from "zod";
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
import z, { number } from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { use } from "react";
import { CACHE_KEYS, getOrCached } from "../cache";
import { host } from "@/bun/utils/host";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -123,22 +128,52 @@ export default new Elysia()
})
.get('/games', async ({ query, set }) =>
{
const where: any[] = [];
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
if (!query.collection_id)
if (query.source === 'store')
{
const shuffledGames = await getShuffledStoreGames();
set.headers['x-max-items'] = shuffledGames.length;
const storeGames = await Promise.all(shuffledGames
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
} else
{
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
@ -153,52 +188,30 @@ export default new Elysia()
.where(and(...where));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
if (!query.collection_id)
{
return convertLocalToFrontend(g);
}));
}
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id,
limit: query.limit,
offset: query.offset
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
{
return convertRomToFrontend(g);
}));
}
if (query.source === 'store')
{
const gamesManifest = await getStoreGameManifest();
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
const storeGames = await Promise.all(gamesManifest
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
.map(async (e) =>
games.push(...localGames.map(g =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
return convertLocalToFrontend(g);
}));
games.push(...storeGames.filter(g => g !== undefined));
}
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id,
limit: query.limit,
offset: query.offset
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
{
return convertRomToFrontend(g);
}));
}
}
return { games };
@ -231,92 +244,59 @@ export default new Elysia()
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
async function getLocalGameDetailed (match: any)
const sourceData = await getSourceGameDetailed(source, id);
if (sourceData)
{
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
if (localGame)
if (sourceData.platform_slug)
{
const exists = await checkInstalled(localGame.path_fs);
const fileSize = await calculateSize(localGame.path_fs);
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,
path_fs: localGame.path_fs,
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
}
return undefined;
}
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return status('Not Found');
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (localGame) return localGame;
if (source === 'romm')
{
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping)
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
{
if (data)
{
const systems = await buildStoreFrontendEmulatorSystems(data);
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
}
else if (name === 'EMULATORJS')
{
return {
name: 'EMULATORJS',
validSource: { binPath: SERVER_URL(host), type: 'js', exists: true },
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [],
gameCount: 0
} satisfies FrontEndGameTypeDetailedEmulator;
}
else
{
return {
name: name,
logo: "",
systems: [],
gameCount: 0
} satisfies FrontEndGameTypeDetailedEmulator;
}
}));
}
return status("Not Found", rom.response);
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return status("Not Found");
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
}
return sourceData;
} else
{
return status("Not Found");
}
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
{
set.headers["content-type"] = 'text/event-stream';
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return buildStatusResponse(source, id);
}, {
response: z.any(),
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ isLocal: z.boolean().optional() })
})
.use(buildStatusResponse())
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
{
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
@ -332,11 +312,11 @@ export default new Elysia()
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
if (!taskQueue.hasActive())
if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob))
{
if (source === 'romm' || source === 'store')
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true }));
return status(200);
}
@ -349,7 +329,20 @@ export default new Elysia()
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
const job = taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob);
if (job)
{
job.abort('cancel');
return status('OK');
}
return status('Not Found');
}, {
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands)
@ -362,11 +355,11 @@ export default new Elysia()
{
try
{
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
launchCommand(validCommand.command, source, id, validCommands.gameId);
launchCommand(validCommand, source, id, validCommands.gameId);
return { type: 'application', command: null };
} else
{
@ -382,7 +375,7 @@ export default new Elysia()
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ command_id: z.number().or(z.string()).optional() }),
body: z.object({ command_id: z.number().or(z.string()).optional() }),
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
})
.post("/stop", async ({ }) =>
@ -404,4 +397,190 @@ export default new Elysia()
.get('/emulatorjs/data/*', async () =>
{
return status("Not Found");
})
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
{
const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const systemsIdSet = new Set(systems.map(s => s.id));
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
{
return convertLocalToFrontend(g);
}).slice(0, 3));
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
if (rommPlatforms)
{
const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id);
if (platformIds.length > 0)
{
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: platformIds
}
});
let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size);
for (const slug of systemsRommSlugSet)
{
const systemRommGames = rommGames.data?.items.filter(g => !localGamesSet?.has(`romm@${g.id}`) && slug === g.platform_slug).map(g =>
{
return convertRomToFrontend(g);
}).slice(0, gamesPerSystem) ?? [];
games.push(...systemRommGames);
}
}
}
const gamesManifest = await getStoreGameManifest();
const storeGames = await Promise.all(gamesManifest
.filter(g => systemsIdSet.has(path.dirname(g.path)))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
return games;
})
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
{
const sourceData = await getSourceGameDetailed(source, id);
if (!sourceData) return status("Not Found");
const sourceCompaniesSet = new Set(sourceData.companies);
const sourceGenresSet = new Set(sourceData.genres);
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
const games: (FrontEndGameType & { metadata?: any; })[] = [];
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.groupBy(schema.games.id);
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata })));
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
if (rommPlatforms)
{
const rommPlatform = rommPlatforms.find(p => p.slug === sourceData.platform_slug);
if (rommPlatform)
{
const rommGames = await getRomsApiRomsGet({ query: { genres: sourceData.genres, genres_logic: 'any' } });
if (rommGames.data)
{
games.push(...rommGames.data.items.filter(g => !localGamesSourceSet.has(`romm@${g.id}`)).map(g => ({ ...convertRomToFrontend(g), metadata: g.metadatum })));
}
}
}
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`${system}@${id}`))
return false;
if (esSystem)
{
if (path.dirname(g.path) === esSystem.system) return true;
}
return false;
})
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
const rankedGames = games.filter(g =>
{
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
{
return false;
}
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
{
return false;
}
return true;
}).map(g =>
{
let rank = random.next();
if (g.platform_slug === sourceData.platform_slug)
rank += 1;
if (g.metadata)
{
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
{
rank += 1;
}
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
{
rank += 1;
}
}
return { rank: rank, game: g };
});
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
});

View file

@ -1,7 +1,7 @@
import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
import z from "zod";
import { count, eq, getTableColumns } from "drizzle-orm";
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
import { db } from "../app";
import { FrontEndPlatformType } from "@shared/constants";
import * as schema from "@schema/app";
@ -25,17 +25,35 @@ export default new Elysia()
{
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
{
const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } });
const screenshots: string[] = [];
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data);
if (rommGames)
{
const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`);
if (rommScreenshots)
screenshots.push(...rommScreenshots);
}
if (screenshots.length <= 0)
{
const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1);
if (localScreenshots)
screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`));
}
const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id);
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.display_name,
family_name: p.family_name,
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
game_count: p.rom_count,
game_count: p.rom_count + localGames.length,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: String(p.id) },
hasLocal: localPlatformSet.has(p.slug),
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
paths_screenshots: screenshots
};
return platform;

View file

@ -5,16 +5,18 @@ import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators';
import * as appSchema from "@schema/app";
import { eq } from 'drizzle-orm';
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
import { spawn } from 'node:child_process';
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
import { CommandEntry } from '@/shared/constants';
import { CommandEntry, EmulatorSourceType } from '@/shared/constants';
import { cores } from '../../emulatorjs/emulatorjs';
export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g;
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number)
{
if (activeGame && activeGame.process?.killed === false)
{
@ -31,8 +33,9 @@ export async function launchCommand (validCommand: string, source: string, sourc
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand, {
shell: true
const game = spawn(validCommand.command, {
shell: true,
cwd: validCommand.startDir
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
@ -99,6 +102,54 @@ export async function launchCommand (validCommand: string, source: string, sourc
}*/
}
/**
* Get the emulators related to the given system
* @param systemSlug the ES-DE slug for the system
*/
export async function getEmulatorsForSystem (systemSlug: string)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const emulators = new Set<string>();
await Promise.all(system.commands.map(async (command, index) =>
{
let cmd = command.command;
const matches = Array.from(cmd.matchAll(varRegex));
matches.forEach(([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
emulators.add(emulatorName);
return;
}
});
}));
if (cores[systemSlug])
{
emulators.add('EMULATORJS');
}
return Array.from(emulators);
}
/**
*
* @param data Uses es-de system slug
* @returns
*/
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
@ -160,101 +211,151 @@ export async function getValidLaunchCommands (data: {
}
}
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
function escapeWindowsArg (arg: string): string
{
const label = command.label;
let cmd = command.command;
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
}
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
'%ESPATH%': $.escape(path.dirname(Bun.main)),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': $.escape(path.basename(validFiles[0]))
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
const formattedCommands = await Promise.all(system.commands
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
.map(async (command, index) =>
{
try
const label = command.label;
let cmd = command.command;
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': escapeWindowsArg(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
'%ROMPATH%': escapeWindowsArg(gamePath),
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
'%ESCAPESPECIALS%': "",
'%HIDEWINDOW%': ""
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
{
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
try
{
return staticVars[a] ?? a;
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let execs = await findExecsByName(emulatorName);
let validExec = execs.find(e => e.exists);
emulator = emulatorName;
return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
let startDir: string | undefined = undefined;
if ('%STARTDIR%' in vars)
{
delete vars['%STARTDIR%'];
cmd = cmd.replace(assignRegex, (match, p1, p2) =>
{
if (p1 === '%STARTDIR%')
{
startDir = varRegex.test(p2) ? staticVars[p2] : p2;
}
return "";
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExecByName(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
}
emulator = emulatorName;
return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
startDir,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
}
export async function findExecByName (emulatorName: string)
export async function findExecsByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return findExec(emulator);
return findExecs(emulatorName, emulator);
}
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined
{
if (os.platform() === 'win32')
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
if (storeExecName)
{
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
}
return undefined;
}
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
const execs: EmulatorSourceType[] = [];
if (customEmulators.has(id))
{
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
}
if (emulator && emulator.systempath.length > 0)
{
const storePath = findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath);
}
if (emulator && os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
@ -264,32 +365,32 @@ export async function findExec (emulator: { winregistrypath: string[], systempat
const registryValue = await readRegistryValue(node);
if (registryValue)
{
return { path: registryValue, type: 'registry' };
execs.push({ binPath: registryValue, type: 'registry', exists: true });
}
}
}
}
const systempaths = emulator.systempath;
if (systempaths.length > 0)
if (emulator && emulator.systempath.length > 0)
{
const systemPath = await resolveSystemPath(systempaths);
const systemPath = await resolveSystemPath(emulator.systempath);
if (systemPath)
{
return { path: systemPath, type: 'system' };
execs.push({ binPath: systemPath, type: 'system', exists: true });
}
}
const staticPaths = emulator.staticpath;
if (staticPaths.length > 0)
if (emulator && emulator.staticpath.length > 0)
{
const staticPath = await resolveStaticPath(staticPaths);
const staticPath = await resolveStaticPath(emulator.staticpath);
if (staticPath)
{
return { path: staticPath, type: 'static' };
execs.push({ binPath: staticPath, type: 'static', exists: true });
}
}
return execs;
}
async function readRegistryValue (text: string)

View file

@ -11,6 +11,10 @@ import { ErrorLike } from "elysia/universal";
import { getStoreGameFromId } from "../../store/services/gamesService";
import { cores } from "../../emulatorjs/emulatorjs";
import { host } from "@/bun/utils/host";
import Elysia from "elysia";
import z from "zod";
import data from "@emulators";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
class CommandSearchError extends Error
{
@ -54,8 +58,11 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'emulatorjs',
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
id: 'EMULATORJS',
label: "Emulator JS",
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
valid: true,
emulator: 'EMULATORJS'
});
}
@ -89,97 +96,93 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
return undefined;
}
export default async function buildStatusResponse (source: string, id: string)
export default function buildStatusResponse ()
{
let cleanup: (() => void) | undefined;
let closed = false;
return new Response(new ReadableStream({
async start (controller)
return new Elysia().ws('/status/:source/:id', {
response: z.discriminatedUnion('status', [
z.object({ status: z.literal('error'), error: z.unknown() }),
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
z.object({ status: z.literal(['refresh', 'queued']) }),
z.object({ status: z.literal('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string() }),
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
]),
message (ws, data)
{
const encoder = new TextEncoder();
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
if (data === 'cancel')
{
if (closed) return;
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
activeTask?.abort('cancel');
}
await sendLatests();
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
const keepAlive = setInterval(() =>
{
if (closed) return clearInterval(keepAlive);
try
{
enqueue({}, 'ping');
} catch
{
closed = true;
clearInterval(keepAlive);
}
}, 15000);
const sourceId = `${source}-${id}`;
},
async open (ws)
{
sendLatests();
async function sendLatests ()
{
if (closed) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (ws.readyState > 1) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
if (activeTask)
{
enqueue({
progress: activeTask.progress,
status: activeTask.state as any
});
if (activeTask.status === 'queued')
{
ws.send({ status: 'queued' });
} else
{
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
}
} else if (activeGame && activeGame.gameId === localGame?.id)
{
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
ws.send({ status: 'playing', details: 'Playing' });
}
else
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
if (validCommand)
{
if (validCommand instanceof Error)
{
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
ws.send({ status: 'error', error: validCommand.message });
}
else
{
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
ws.send({
status: 'installed',
details: validCommand.commands[0].label,
commands: validCommand.commands
});
}
}
else if (source === 'romm')
else if (ws.data.params.source === 'romm')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(ws.data.params.id) } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
ws.send({ status: 'install', details: 'Install' });
}
} else if (source === 'store')
} else if (ws.data.params.source === 'store')
{
const storeGame = await getStoreGameFromId(id);
const storeGame = await getStoreGameFromId(ws.data.params.id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath'));
if (size > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
ws.send({ status: 'install', details: 'Install' });
}
}
}
@ -190,50 +193,56 @@ export default async function buildStatusResponse (source: string, id: string)
{
if (data.error)
{
enqueue({
ws.send({
status: 'error',
error: data.error
}, 'error');
});
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);
dispose.push(() => events.off('activegameexit', handleActiveExit));
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
dispose.push(taskQueue.on('progress', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({ progress, status: state as any });
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
}
}));
dispose.push(taskQueue.on('completed', ({ id }) =>
dispose.push(taskQueue.on('queued', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({}, 'refresh');
ws.send({ status: 'queued' });
}
}));
dispose.push(taskQueue.on('error', ({ id, error }) =>
dispose.push(taskQueue.on('completed', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({
ws.send({ status: 'refresh' });
}
}));
dispose.push(taskQueue.on('error', (data) =>
{
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
ws.send({
status: 'error',
error: getErrorMessage(error)
}, 'error');
error: getErrorMessage(data.error)
});
}
}));
cleanup = () =>
(ws.data as any).cleanup = () =>
{
closed = true;
dispose.forEach(f => f());
};
},
cancel ()
close (ws, code, reason)
{
cleanup?.();
cleanup = undefined;
(ws.data as any).cleanup?.();
},
}));
});
}

View file

@ -1,12 +1,14 @@
import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config, emulatorsDb } from "../../app";
import { config, db, emulatorsDb } from "../../app";
import { and, eq } from "drizzle-orm";
import * as schema from "@schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
import * as emulatorSchema from "@schema/emulators";
import romm from "@/mainview/scripts/queries/romm";
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
export async function calculateSize (installPath: string | null)
{
@ -127,7 +129,7 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
slug: null,
name: storeGame.title,
platform_id: null,
platform_slug: system,
platform_slug: rommSystem?.sourceSlug ?? system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
};
@ -157,21 +159,138 @@ export async function convertStoreToFrontendDetailed (system: string, id: string
return detailed;
}
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
export async function convertRomToFrontendDetailed (rom: DetailedRomSchema)
{
const detailed: FrontEndGameTypeDetailed = {
...convertRomToFrontend(rom),
summary: rom.summary,
fs_size_bytes: rom.fs_size_bytes,
local: false,
missing: rom.missing_from_fs
missing: rom.missing_from_fs,
genres: rom.metadatum.genres,
companies: rom.metadatum.companies,
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined
};
const userData = await getCurrentUserApiUsersMeGet();
const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id);
if (rom.merged_ra_metadata?.achievements)
{
const earnedMap = new Map<string, { date: Date; date_hardcode?: Date; }>(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }]));
detailed.achievements = {
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
unlocked: gameAchievements?.num_awarded ?? 0,
entires: rom.merged_ra_metadata.achievements.map(a =>
{
const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined;
const ach: FrontEndGameTypeDetailedAchievement = {
id: a.badge_id ?? String(a.ra_id) ?? 'unknown',
title: a.title ?? "Unknown",
badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined,
date: earned?.date,
date_hardcode: earned?.date_hardcode,
description: a.description ?? undefined,
display_order: a.display_order ?? 0,
type: a.type ?? undefined
};
return ach;
}).sort((a, b) => a.display_order - b.display_order),
total: rom.merged_ra_metadata.achievements.length
};
}
return detailed;
}
export async function getLocalGameDetailed (match: any)
{
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
if (localGame)
{
const exists = await checkInstalled(localGame.path_fs);
const fileSize = await calculateSize(localGame.path_fs);
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,
path_fs: localGame.path_fs,
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
}
return undefined;
}
export async function getSourceGameDetailed (source: string, id: string)
{
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return undefined;
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (source === 'romm')
{
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
{
const romGame = await convertRomToFrontendDetailed(rom.data);
if (localGame)
{
return {
...romGame,
...localGame,
};
}
return romGame;
}
else if (localGame)
{
return localGame;
}
return undefined;
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return undefined;
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
if (localGame)
{
return { ...storeFrontendGame, ...localGame };
}
return storeFrontendGame;
} else if (localGame)
{
return localGame;
}
return undefined;
}
}

View file

@ -0,0 +1,105 @@
import { EmulatorPackageType } from "@/shared/constants";
import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "../task-queue";
import z from "zod";
import { Glob } from "bun";
import { config } from "../app";
import path from 'node:path';
import { getOrCachedGithubRelease } from "../cache";
import _7z from '7zip-min';
import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader";
import { move } from "fs-extra";
type EmulatorDownloadStates = "download" | "extract";
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
{
static id = "download-emulator" as const;
static dataSchema = z.object({ emulator: z.string() });
emulator: string;
downloadSource: string;
emulatorPackage?: EmulatorPackageType;
constructor(emulator: string, downloadSource: string)
{
this.emulator = emulator;
this.downloadSource = downloadSource;
}
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
{
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found");
if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads");
const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`];
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
if (!validDownload || !validDownload.path) throw new Error(`Download type ${this.downloadSource} not found`);
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
const glob = new Glob(validDownload.pattern);
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
if (!validAsset) throw new Error("Could Not Find Valid Asset");
const downloadUrl = validAsset.browser_download_url;
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
const isArchive = validAsset.content_type === 'application/x-7z-compressed' || validAsset.name.endsWith('.7z') || validAsset.content_type === 'application/zip' || validAsset.name.endsWith('.zip');
const isAppImage = validAsset.name.endsWith(".AppImage");
if (!isArchive && !isAppImage)
{
throw new Error("Invalid Download Type");
}
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.emulator,
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
tmpFolder,
{
onProgress (stats)
{
context.setProgress(stats.progress, 'download');
},
});
const destinationPaths = await downloader.start();
if (destinationPaths)
{
if (isArchive)
{
if (await downloader.start() && destinationPaths[0])
{
let destinationPath = destinationPaths[0];
await _7z.unpack(destinationPath, emulatorsFolder);
await fs.rm(destinationPath, { recursive: true });
// check if 1 root folder we need to get rid of
const contents = await fs.readdir(emulatorsFolder);
if (contents.length === 1)
{
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
if (stat.isDirectory())
{
console.log("Found 1 root folder, using that instead");
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
}
}
}
}
}
}
exposeData ()
{
return { emulator: this.emulator };
}
}

View file

@ -6,12 +6,14 @@ import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path from 'node:path';
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
import { config, db, emulatorsDb, jar } from "../app";
import unzip from 'unzip-stream';
import { Readable, Transform } from "node:stream";
import { config, db, emulatorsDb, events, jar } from "../app";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import { hashFile } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import { sleep } from "bun";
import _7z from '7zip-min';
interface JobConfig
{
@ -19,13 +21,16 @@ interface JobConfig
dryDownload?: boolean;
}
export class InstallJob implements IJob
export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<never, InstallJobStates>
{
public gameId: string;
public source: string;
public sourceId: string;
public config?: JobConfig;
static id = "install-job" as const;
public group = InstallJob.id;
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
{
@ -35,162 +40,124 @@ export class InstallJob implements IJob
this.source = source;
}
public async start (cx: JobContext)
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
{
cx.setProgress(0, 'download');
fs.mkdir(config.get('downloadPath'), { recursive: true });
const downloadPath = config.get('downloadPath');
let files: {
url: URL,
file_path: string;
file_name: string;
size?: number;
}[] = [];
let cookie: string = '';
let screenshotUrls: string[];
let coverUrl: string;
let rommPlatform: PlatformSchema | undefined;
let slug: string | null;
let path_fs: string | undefined;
let summary: string | null;
let name: string | null;
let last_played: Date | null;
let igdb_id: number | null;
let ra_id: number | null;
let source_id: string;
let system_slug: string;
let extract_path: string;
let metadata: any | undefined;
switch (this.source)
{
case 'romm':
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
const rommAddress = config.get('rommAddress');
coverUrl = `${rommAddress}${rom.path_cover_large}`;
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
igdb_id = rom.igdb_id;
ra_id = rom.ra_id;
summary = rom.summary;
name = rom.name;
path_fs = path.join(rom.fs_path, rom.fs_name);
source_id = String(rom.id);
slug = rom.slug;
system_slug = rommPlatform.slug;
extract_path = '';
metadata = rom.metadatum;
const rommFiles = await Promise.all(rom.files.map(async f =>
{
const localPath = path.join(config.get('downloadPath'), f.full_path);
if (f.md5_hash && await fs.exists(localPath))
{
const existingHash = await hashFile(localPath, 'sha1');
if (existingHash === f.md5_hash)
{
console.log("File Already Present: ", f.full_path);
return undefined;
}
console.warn("File ", f.full_path, 'with hash', existingHash, 'has different hash than', f.sha1_hash);
}
return {
url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`),
file_name: f.file_name,
file_path: path.join(config.get('downloadPath'), f.file_path),
size: f.file_size_bytes
};
}));
files.push(...rommFiles.filter(f => f !== undefined));
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
break;
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
coverUrl = game.pictures.titlescreens[0];
screenshotUrls = game.pictures.screenshots;
files.push({ url: new URL(game.file), file_path: `roms/${game.system}`, file_name: path.basename(decodeURI(game.file)) });
slug = this.gameId;
source_id = this.gameId;
name = game.title;
summary = game.description;
system_slug = gameId.system;
extract_path = path.join('roms', gameId.system);
break;
default:
throw new Error("Unsupported source");
}
if (this.config?.dryRun !== true)
{
const downloadPath = config.get('downloadPath');
let downloadUrl: URL;
let cookie: string = '';
let screenshotUrls: string[];
let coverUrl: string;
let rommPlatform: PlatformSchema | undefined;
let slug: string | null;
let path_fs: string | undefined;
let summary: string | null;
let name: string | null;
let last_played: Date | null;
let igdb_id: number | null;
let ra_id: number | null;
let source_id: string;
let system_slug: string;
let extract_path: string;
switch (this.source)
{
case 'romm':
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
const rommAddress = config.get('rommAddress');
coverUrl = `${rommAddress}${rom.path_cover_large}`;
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
igdb_id = rom.igdb_id;
ra_id = rom.ra_id;
summary = rom.summary;
name = rom.name;
path_fs = path.join(rom.fs_path, rom.fs_name);
source_id = String(rom.id);
slug = rom.slug;
system_slug = rommPlatform.slug;
extract_path = '';
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
break;
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
coverUrl = game.pictures.titlescreens[0];
screenshotUrls = game.pictures.screenshots;
downloadUrl = new URL(game.file);
slug = this.gameId;
source_id = this.gameId;
name = game.title;
summary = game.description;
system_slug = gameId.system;
extract_path = 'roms', gameId.system;
break;
default:
throw new Error("Unsupported source");
}
if (this.config?.dryDownload !== true)
{
/*
// download files for rom
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
},
fileName: `${this.id}.zip`,
// Romm doesn't support resume download
override: true
});
cx.abortSignal.addEventListener('abort', downloader.stop);
downloader.on('progress.throttled', e =>
{
cx.setProgress(e.progress, 'download');
});
downloader.on('error', (e) =>
{
cx.abort(e);
});
const finishPromise = new Promise<string>(resolve =>
{
downloader.on("end", ({ filePath }) => resolve(filePath));
});
await downloader.start().catch(err => console.error(err));
const zipFilePath = await finishPromise;
cx.setProgress(0, 'extract');
const zip = new StreamZip.async({ file: zipFilePath });
const totalCount = await zip.entriesCount;
let extractCount = 0;
zip.on('extract', async (entry, file) =>
{
console.log(`Extracted ${entry.name} to ${file}`);
cx.setProgress(extractCount / totalCount * 100, 'extract');
extractCount++;
});
await zip.extract(null, downloadPath);
await zip.close();
await fs.rm(zipFilePath);*/
cx.setProgress(0, 'download');
const res = await fetch(downloadUrl, {
headers: {
cookie: cookie
},
});
const totalBytes = Number(res.headers.get("content-length")) || 0;
let bytesReceived = 0;
const progressStream = new Transform({
transform (chunk, _, callback)
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
files,
config.get('downloadPath'),
{
bytesReceived += chunk.length;
if (totalBytes > 0)
signal: cx.abortSignal,
onProgress (stats)
{
const percent = (bytesReceived / totalBytes) * 100;
cx.setProgress(percent, 'download');
}
this.push(chunk);
callback();
}
});
await new Promise((resolve, reject) =>
{
const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), });
(extract as any).unzipStream.on('entry', (entry: any) =>
{
if (!path_fs)
path_fs = path.join(extract_path, entry.path);
cx.setProgress(stats.progress, 'download');
},
});
Readable.fromWeb(res.body as any).pipe(progressStream)
.pipe(extract)
.on('close', resolve)
.on('error', reject);
});
const downloadedFiles = await downloader.start();
if (extract_path && downloadedFiles)
{
for (const path of downloadedFiles)
{
await _7z.unpack(path, extract_path);
}
}
}
if (this.config?.dryDownload === true)
@ -198,8 +165,6 @@ export class InstallJob implements IJob
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
}
const coverResponse = await fetch(coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer());
@ -291,7 +256,8 @@ export class InstallJob implements IJob
summary: summary,
name,
cover,
cover_type: coverResponse.headers.get('content-type')
cover_type: coverResponse.headers.get('content-type'),
metadata
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
@ -327,7 +293,17 @@ export class InstallJob implements IJob
}
});
} else
{
for (let i = 0; i < 10; i++)
{
cx.setProgress(i * 10, "download");
if (cx.abortSignal.aborted) return;
await sleep(1000);
}
}
events.emit('notification', { message: `${name}: Installed`, type: 'success', duration: 8000 });
}
}

View file

@ -1,13 +1,21 @@
import Elysia from "elysia";
import z, { } from "zod";
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
import { taskQueue } from "../app";
import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store";
import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils";
import { IJob } from "../task-queue";
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (_job: T, path: Path, dataSchema: TS)
function registerJob<
const Path extends string,
const Schema extends ZodTypeAny,
const States extends string,
T extends IJob<z.infer<Schema>, States>
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
{
return new Elysia().ws(path, {
return new Elysia().ws(_job.id, {
body: z.discriminatedUnion('type', [
z.object({ type: z.literal('cancel') })
]),
@ -16,14 +24,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
type: z.literal(['data', 'started', 'progress']),
status: z.string(),
progress: z.number(),
data: dataSchema
data: _job.dataSchema
}),
z.object({ type: z.literal(['completed', 'ended']) }),
z.object({ type: z.literal('error'), error: z.unknown() })
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
z.object({ type: z.literal('error'), error: z.string() })
]),
open (ws)
{
const job = taskQueue.findJob(path);
const job = taskQueue.findJob(_job.id, _job);
if (job)
{
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
@ -32,30 +40,37 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
(ws.data as any).cleanup = [
taskQueue.on('started', ({ id, job }) =>
{
if (id === path)
if (id === _job.id)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('progress', ({ id, job }) =>
{
if (id === path)
if (id === _job.id)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('completed', ({ id }) =>
taskQueue.on('completed', ({ id, job }) =>
{
if (id === path)
if (id === _job.id)
{
ws.send({ type: 'completed' });
ws.send({ type: 'completed', data: job.job.exposeData?.() });
}
}),
taskQueue.on('ended', ({ id, job }) =>
{
if (id === _job.id)
{
ws.send({ type: 'ended', data: job.job.exposeData?.() });
}
}),
taskQueue.on('error', ({ id, error }) =>
{
if (id === path)
if (id === _job.id)
{
ws.send({ type: 'error', error: error });
ws.send({ type: 'error', error: getErrorMessage(error) });
}
})
];
@ -68,13 +83,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
{
if (message.type === 'cancel')
{
taskQueue.findJob(path)?.abort('cancel');
taskQueue.findJob(_job.id, _job)?.abort('cancel');
}
},
});
}
export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
.use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob))
.use(registerJob(UpdateStoreJob))
.use(registerJob(EmulatorDownloadJob));

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia";
import { IJob, JobContext } from "../task-queue";
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors";
@ -8,7 +8,7 @@ import { config } from "../app";
import z from "zod";
import { delay } from "@/shared/utils";
export class LoginJob implements IJob
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
{
endsAt: Date;
startedAt: Date;
@ -25,7 +25,7 @@ export class LoginJob implements IJob
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
async start (context: JobContext): Promise<any>
async start (context: JobContext<LoginJob, z.infer<typeof LoginJob.dataSchema>, "base">): Promise<void>
{
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
.use(cors())

View file

@ -16,7 +16,9 @@ interface TwitchDevice
verification_uri: string;
}
export default class TwitchLoginJob implements IJob
type States = "Retrieving Device" | "Waiting For Authentication";
export default class TwitchLoginJob implements IJob<z.infer<typeof TwitchLoginJob.dataSchema>, States>
{
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
device?: TwitchDevice;
@ -38,7 +40,7 @@ export default class TwitchLoginJob implements IJob
user_code: this.device.user_code
}) : undefined;
async start (context: JobContext): Promise<any>
async start (context: JobContext<TwitchLoginJob, z.infer<typeof TwitchLoginJob.dataSchema>, States>): Promise<any>
{
context.setProgress(0, "Retrieving Device");
let res = await fetch("https://id.twitch.tv/oauth2/device", {

View file

@ -1,12 +1,14 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { getStoreFolder } from "../store/store";
import { getStoreFolder } from "../store/services/gamesService";
import z from "zod";
export default class UpdateStoreJob implements IJob
export default class UpdateStoreJob implements IJob<never, never>
{
static id = "update-store" as const;
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
static branch = "master";
static dataSchema = z.never();
async gitCommand (commands: string[], dir: string)
{
@ -40,8 +42,10 @@ export default class UpdateStoreJob implements IJob
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
}
async start (context: JobContext)
async start (context: JobContext<UpdateStoreJob, never, never>)
{
if (process.env.CUSTOM_STORE_PATH) return;
const storeFolder = getStoreFolder();
await ensureDir(storeFolder);
context.setProgress(10);

View file

@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const emulators = sqliteTable('emulators', {
name: text().primaryKey().unique(),
fullname: text(),
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
staticpath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),

View file

@ -1,12 +1,13 @@
import * as appSchema from '@schema/app';
import { findExecByName } from "../games/services/launchGameService";
import * as emulatorSchema from "@schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import { customEmulators, db, emulatorsDb } from '../app';
import fs from 'node:fs/promises';
import { cores } from '../emulatorjs/emulatorjs';
import { FrontEndEmulator } from '@/shared/constants';
import { FrontEndEmulator, SERVER_URL } from '@/shared/constants';
import { findExecsByName } from '../games/services/launchGameService';
import { host } from '@/bun/utils/host';
/**
* Get emulators based on local games. Only the ones we probably need.
@ -53,14 +54,8 @@ export async function getRelevantEmulators ()
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: { path: string; type: string, } | undefined;
if (customEmulators.has(emulator))
{
execPath = { path: customEmulators.get(emulator), type: 'custom' };
} else
{
execPath = await findExecByName(emulator);
}
const execPaths = await findExecsByName(emulator);
const validExecPath = execPaths.find(e => e.exists);
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
@ -68,45 +63,31 @@ export async function getRelevantEmulators ()
{
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
}
// check if automatic or custom path found existing binary.
// This might not be the actual emulator but I don't care.
const exists = !!execPath && await fs.exists(execPath.path);
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
if (exists)
if (validExecPath)
{
systems.forEach(s => platformViability.set(s, true));
}
const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = {
const em: FrontEndEmulator & { isCritical: boolean; } = {
name: emulator,
exists: exists,
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
gameCount: 0,
description: '',
homepage: '',
type: 'emulator',
os: [process.platform as any],
isCritical: false,
path: execPath,
validSource: validExecPath
};
return em;
}));
finalEmulators.push({
name: 'emulatorjs',
exists: true,
path: { path: 'localhost', type: 'js' },
name: 'EMULATORJS',
validSource: { binPath: `${SERVER_URL(host)}`, type: 'js', exists: true },
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [],
gameCount: 0,
type: 'emulator',
description: '',
homepage: '',
os: [process.platform as any],
isCritical: false
isCritical: false,
});
return finalEmulators.map(e =>

View file

@ -0,0 +1,31 @@
import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants";
import { emulatorsDb } from "../../app";
import * as emulatorSchema from '@schema/emulators';
import { findExecs } from "../../games/services/launchGameService";
import { eq } from "drizzle-orm";
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: {
id: string;
name: string;
icon: string;
}[])
{
let execPath: EmulatorSourceType | undefined;
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
if (esEmulator)
{
const allExecs = await findExecs(emulator.name, esEmulator);
if (allExecs.length > 0) execPath = allExecs[0];
}
const em: FrontEndEmulator = {
name: emulator.name,
logo: emulator.logo,
systems,
gameCount,
validSource: execPath
};
return em;
}

View file

@ -1,5 +1,22 @@
import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
import { CACHE_KEYS, getOrCached } from "../../cache";
import { and, eq } from "drizzle-orm";
import { config, emulatorsDb } from '../../app';
import path from "node:path";
import fs from 'node:fs/promises';
import * as emulatorSchema from '@schema/emulators';
import { shuffleInPlace } from "@/bun/utils";
export async function getShuffledStoreGames ()
{
return getOrCached('shuffled-store-games', async () =>
{
const gamesManifest = await getStoreGameManifest();
const allStoreGames = gamesManifest.filter(g => g.type === 'blob');
shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60));
return allStoreGames;
}, { expireMs: 1000 / 60 / 60 });
}
export async function getStoreGameManifest ()
{
@ -57,3 +74,54 @@ export async function getStoreGameFromPath (path: string)
.then(g => StoreGameSchema.parseAsync(g)));
return game;
}
export function getStoreFolder ()
{
if (process.env.CUSTOM_STORE_PATH) return process.env.CUSTOM_STORE_PATH;
const downlodDir = config.get('downloadPath');
return path.join(downlodDir, "store");
}
export async function getStoreEmulatorPackage (id: string)
{
const emulatorPath = path.join(getStoreFolder(), "buckets", "emulators", `${id}.json`);
if (await fs.exists(emulatorPath))
return EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
return undefined;
}
export async function getAllStoreEmulatorPackages ()
{
const emulatorsBucket = path.join(getStoreFolder(), "buckets", "emulators");
const emulators = await fs.readdir(emulatorsBucket);
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
{
if (e.error)
{
console.error(e.error);
}
return e.data;
}).map(e => e.data!);
return emulatesParsed;
}
export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType)
{
const systems = await Promise.all(emulator.systems.map(async system =>
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
});
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
return { id: system, romm_slug: rommSystem?.sourceSlug, name: esSystem?.fullname ?? system, icon: icon };
}));
return systems;
}

View file

@ -1,61 +1,19 @@
import Elysia from "elysia";
import { config, customEmulators, db } from "../app";
import Elysia, { status } from "elysia";
import { config, db, taskQueue } from "../app";
import path from "node:path";
import fs from 'node:fs/promises';
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
import { findExec } from "../games/services/launchGameService";
import { emulatorsDb } from '../app';
import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators';
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
import { findExecsByName } from "../games/services/launchGameService";
import * as appSchema from '@schema/app';
import z from "zod";
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
import { CACHE_KEYS, getOrCached } from "../cache";
export function getStoreFolder ()
{
const downlodDir = config.get('downloadPath');
return path.join(downlodDir, "store");
}
async function getAllStoreEmulatorPackages ()
{
const downlodDir = config.get('downloadPath');
const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators");
const emulators = await fs.readdir(emulatorsBucket);
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
{
if (e.error)
{
console.error(e.error);
}
return e.data;
}).map(e => e.data!);
return emulatesParsed;
}
async function buildSystems (emulator: EmulatorPackageType)
{
const systems = await Promise.all(emulator.systems.map(async system =>
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
});
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
return { id: system, name: esSystem?.fullname ?? system, icon: icon };
}));
return systems;
}
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
import { Glob } from "bun";
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
export const store = new Elysia({ prefix: '/api/store' })
.get('/emulators', async ({ query }) =>
@ -70,27 +28,10 @@ export const store = new Elysia({ prefix: '/api/store' })
.filter(e => e.os.includes(process.platform as any))
.map(async (emulator) =>
{
let execPath: { path: string; type: string; } | undefined;
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
if (esEmulator)
{
if (customEmulators.has(emulator?.name))
{
execPath = { path: customEmulators.get(emulator.name), type: 'custom' };
} else
{
execPath = await findExec(esEmulator);
}
}
const exists = !!execPath && await fs.exists(execPath.path);
const systems = await buildSystems(emulator);
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const gameCounts = await Promise.all(systems.map(async (s) =>
{
const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) });
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
if (romPlatform)
{
return romPlatform.rom_count;
@ -101,13 +42,12 @@ export const store = new Elysia({ prefix: '/api/store' })
}));
const gameCount = gameCounts.reduce((a, c) => a + c);
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
}));
if (query.missing)
{
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
frontEndEmulators = frontEndEmulators.filter(e => !e.validSource);
}
if (query.orderBy === 'importance')
@ -161,42 +101,65 @@ export const store = new Elysia({ prefix: '/api/store' })
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
},
{ params: z.object({ id: z.string(), name: z.string() }) })
.get('/details/emulator/:id', async ({ params: { id } }) =>
.get('/emulator/:id', async ({ params: { id } }) =>
{
const downlodDir = config.get('downloadPath');
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
const emulatorPackage = await getStoreEmulatorPackage(id);
if (!emulatorPackage) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage);
const execPaths = await findExecsByName(emulatorPackage.name);
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
const systems = await buildSystems(emulatorPackage);
let execPath: { path: string; type: string; } | undefined;
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) });
if (esEmulator)
{
if (customEmulators.has(emulatorPackage?.name))
{
execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' };
} else
{
execPath = await findExec(esEmulator);
}
}
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
const exists = !!execPath && await fs.exists(execPath.path);
const validExec = execPaths.find(p => p.exists);
const emulator: FrontEndEmulatorDetailed = {
...emulatorPackage,
name: emulatorPackage.name,
description: emulatorPackage.description,
systems,
exists,
status: {
source: execPath?.type,
location: execPath?.path
},
validSource: validExec,
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
gameCount: 0
gameCount: 0,
homepage: emulatorPackage.homepage,
downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
{
if (d.type === 'github' && d.path)
{
const release = await getOrCachedGithubRelease(d.path);
const glob = new Glob(d.pattern);
const download: FrontEndEmulatorDetailedDownload = {
name: d.type,
type: release.assets.find(a => glob.match(a.name))?.content_type
};
return download;
};
return { name: d.type, type: "Unknown" };
}) ?? []),
logo: emulatorPackage.logo,
sources: execPaths
};
return emulator;
}, { params: z.object({ id: z.string() }) });
}, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id } }) =>
{
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
{
return status("Conflict", "Installation already running");
}
const job = new EmulatorDownloadJob(id, source);
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
})
.delete('/emulator/:id', async ({ params: { id } }) =>
{
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
if (await fs.exists(storeEmulatorFolder))
{
fs.rm(storeEmulatorFolder, { recursive: true });
return status("OK");
}
return status("Not Found");
});

View file

@ -11,7 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size";
import si from 'systeminformation';
import { getStoreFolder } from "./store/store";
import { getStoreFolder } from "./store/services/gamesService";
export const system = new Elysia({ prefix: '/api/system' })
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>

View file

@ -1,40 +1,44 @@
import { JobStatus } from '@/shared/constants';
import EventEmitter from 'node:events';
import z, { ZodTypeAny } from 'zod';
export class TaskQueue
{
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
public enqueue (id: string, job: IJob): Promise<void>
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
{
this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed");
const context = new JobContext(id, this.events, job);
this.queue.push({ context });
this.events?.emit('queued', { id: context.id, job: context });
return this.processQueue();
}
private processQueue (): Promise<void>
private processQueue ()
{
if (!this.queue) return Promise.resolve();
const top = this.queue.pop();
if (top)
const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job }));
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
next.forEach(job =>
{
const promise = top.context.start();
top.promise = promise;
const index = this.queue.length;
this.activeQueue.push(top);
const promise = job.job.context.start();
job.job.promise = promise;
this.activeQueue.push(job.job);
promise.finally(() =>
{
const index = this.activeQueue.indexOf(job.job);
this.activeQueue.splice(index, 1);
setTimeout(this.processQueue);
setTimeout(() => this.processQueue(), 0);
});
return promise;
}
return Promise.resolve();
});
}
private disposeSafeguard ()
@ -65,10 +69,15 @@ export class TaskQueue
return job?.promise ?? Promise.resolve();
}
public findJob (id: string): IPublicJob | undefined
public findJob<const TData, const TState extends string, const T extends IJob<TData, TState>> (id: string, type: new (...args: any[]) => T): IPublicJob<TData, TState, T> | undefined
{
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
return job?.context;
if (job?.context.job instanceof type)
{
return job?.context;
}
return undefined;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
@ -99,12 +108,13 @@ export interface EventsList
completed: [e: CompletedEvent];
error: [e: ErrorEvent];
ended: [e: BaseEvent];
queued: [e: BaseEvent];
}
interface BaseEvent
{
id: string;
job: IPublicJob;
job: IPublicJob<any, string, any>;
}
interface ErrorEvent extends BaseEvent
@ -128,37 +138,50 @@ interface CompletedEvent extends BaseEvent
}
export interface IJob
export interface IJob<TData, TState extends string>
{
start (context: JobContext): Promise<any>;
exposeData?(): any;
group?: string;
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
exposeData?(): TData;
}
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
export interface IPublicJob
export interface IPublicJob<TData, TState extends string, T extends IJob<TData, TState>>
{
progress: number;
state?: string;
status: JobStatus;
job: IJob;
job: T;
abort: (reason?: any) => void;
}
export class JobContext implements IPublicJob
type JobClass = new (...args: any[]) => IJob<any, any>;
type JobClassWithStatics = JobClass & {
id: string;
dataSchema?: any;
};
export type JobContextFromClass<C extends JobClassWithStatics> =
JobContext<
InstanceType<C>,
C extends { dataSchema: ZodTypeAny; }
? z.infer<C['dataSchema']>
: never,
C['id']
>;
export class JobContext<T extends IJob<TData, TState>, TData, TState extends string> implements IPublicJob<TData, TState, T>
{
private m_id: string;
private m_progress: number = 0;
private m_state?: string;
private m_state?: TState;
private running: boolean = false;
private aborted: boolean = false;
private completed: boolean = false;
private error?: any;
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private readonly m_job: IJob;
private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
constructor(id: string, events: EventEmitter<EventsList>, job: T)
{
this.m_id = id;
this.m_job = job;
@ -202,7 +225,7 @@ export class JobContext implements IPublicJob
if (this.error) return 'error';
if (this.aborted) return 'aborted';
if (this.running) return 'running';
return 'waiting';
return 'queued';
}
public get id () { return this.m_id; }
@ -215,7 +238,11 @@ export class JobContext implements IPublicJob
public get state () { return this.m_state; }
public setProgress (progress: number, state?: string)
/**
* @param progress The 0 to 100 progress
* @param state what type of progress is this. Is it really progress. I humanity even advancing.
*/
public setProgress (progress: number, state?: TState)
{
this.m_progress = progress;
if (state)

View file

@ -8,9 +8,9 @@ import { createInterface } from 'readline';
const api = RunAPIServer();
let bunServer: { stop: () => void; } | undefined;
if (!Bun.env.PUBLIC_ACCESS)
if (!process.env.PUBLIC_ACCESS)
{
bunServer = RunBunServer();
bunServer = await RunBunServer();
}
async function cleanup ()
@ -24,7 +24,7 @@ async function cleanup ()
process.exit(0);
}
if (Bun.env.HEADLESS)
if (process.env.HEADLESS)
{
const rl = createInterface({ input: process.stdin });

View file

@ -8,7 +8,7 @@ import staticPlugin from "@elysiajs/static";
export function RunBunServer ()
{
console.log("Launching Server on port ", SERVER_PORT);
return new Elysia()
const server = new Elysia()
.use(cors())
.headers({
'cross-origin-embedder-policy': 'credentialless',
@ -28,33 +28,11 @@ export function RunBunServer ()
assets: appPath("./dist"),
prefix: "/",
alwaysStatic: true
})).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
/*return Bun.serve({
port: SERVER_PORT,
hostname: host,
routes: {
"/": Bun.file(appPath("./dist/index.html")),
// Serve a file by lazily loading it into memory
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
"/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")),
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
JSON.stringify({
name: appInfo.name,
version: appInfo.version,
debuggable: true,
}),
{
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
}
)
},
fetch: async (req) =>
{
const url = new URL(req.url);
return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`)));
},
});*/
}));
return new Promise<typeof server>((resolve) =>
{
server.onStart(() => resolve(server))
.listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
});
}

View file

@ -6,7 +6,7 @@ export type ActiveGame = {
process?: ChildProcess;
gameId: number;
name: string;
command: string;
command: { command: string, startDir?: string; };
};
interface ObjectConstructor

View file

@ -1,5 +1,7 @@
import { $ } from 'bun';
import path from 'node:path';
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
export function checkRunning (pid: number)
{
@ -69,3 +71,43 @@ export async function openExternal (target: string)
return $`open ${target}`.throws(true);
}
}
export function hashFile (path: string, algorithm: "sha1" | "md5"): Promise<string>
{
return new Promise((resolve, reject) =>
{
const hash = createHash(algorithm);
const stream = createReadStream(path);
stream.on("data", (data) => hash.update(data));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
}
export class SeededRandom
{
seed: number;
constructor(seed?: number)
{
this.seed = seed ?? new Date().getTime();
}
next ()
{
var x = Math.sin(this.seed++) * 10000;
return x - Math.floor(x);
}
}
export function shuffleInPlace (array: any[], startSeed?: number)
{
const random = new SeededRandom(startSeed);
for (let i = array.length - 1; i > 0; i--)
{
const j = Math.floor(random.next() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}

222
src/bun/utils/downloader.ts Normal file
View file

@ -0,0 +1,222 @@
import { ensureDir, move } from "fs-extra";
import path from 'node:path';
import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app";
import { file } from "bun";
export interface FileEntry
{
url: URL;
file_path: string;
file_name: string;
size?: number;
}
export interface ProgressStats
{
progress: number;
}
interface TmpDownloadMetadata
{
files: FileEntry[];
}
export class Downloader
{
files: FileEntry[];
headers?: Record<string, string>;
onProgress?: (stats: ProgressStats) => void;
signal?: AbortSignal;
activeFile?: FileEntry;
downloadPath: string;
id: string;
tmpPath: string;
tmpPathMeta: string;
constructor(
id: string,
files: FileEntry[],
downloadPath: string, init?: {
headers?: Record<string, string>,
onProgress?: (stats: ProgressStats) => void;
signal?: AbortSignal;
})
{
this.files = files;
this.headers = init?.headers;
this.onProgress = init?.onProgress;
this.signal = init?.signal;
this.downloadPath = downloadPath;
this.id = id;
this.tmpPath = path.join(config.get('downloadPath'), 'downloads', this.id);
this.tmpPathMeta = path.join(config.get('downloadPath'), 'downloads', `${this.id}.json`);
}
async updateTmpDownload ()
{
const meta: TmpDownloadMetadata = {
files: this.files
};
await ensureDir(path.join(config.get('downloadPath'), 'downloads'));
await fs.writeFile(this.tmpPathMeta, JSON.stringify(meta));
}
async start ()
{
const totalSize = this.files.reduce((accum, current) => accum += current.size ?? 0, 0);
let bytesReceived = 0;
if (this.files.some(f => path.isAbsolute(f.file_path)))
{
throw new Error("Only Relative Paths Supported");
}
await this.updateTmpDownload();
for (let i = 0; i < this.files.length; i++)
{
const file = this.files[i];
this.activeFile = file;
const cookie = await jar.getCookieString(file.url.href);
await ensureDir(path.join(this.tmpPath, file.file_path));
const filePath = path.join(this.tmpPath, file.file_path, file.file_name);
let start = 0;
// 1. Check existing file
if (await fs.exists(filePath))
{
start = ((await fs.stat(filePath)).size);
}
// 2. Request remaining bytes
let res = await fetch(file.url, {
headers: {
...this.headers,
...(start > 0
? { Range: `bytes=${start}-` }
: undefined),
cookie
}
});
const resSize = Number(res.headers.get("content-length") ?? 0);
if (start > 0)
{
if (res.status === 206)
{
console.log("Resume supported, continuing download");
} else if (res.status === 200)
{
console.log("Server ignored Range, restarting download from beginning");
start = 0;
// Must make a new request from the beginning
res = await fetch(file.url, { headers: { ...this.headers, cookie } });
if (!res.ok)
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
} else if (res.status === 416)
{
const localSize = (await fs.stat(filePath)).size;
if (resSize && localSize === resSize)
{
console.log("File already fully downloaded, skipping");
break;
} else
{
console.log("Partial file corrupt or changed, redownloading");
start = 0;
res = await fetch(file.url, { headers: { ...this.headers, cookie } }); // full download
if (!res.ok)
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
}
}
else
{
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
} else
{
if (!res.ok) throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
}
// 3. Append or overwrite
const stream = createWriteStream(filePath, {
flags: start > 0 ? "a" : "w",
highWaterMark: 64 * 1024
});
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
if (totalSize <= 0)
bytesReceived = 0;
else
bytesReceived += start;
const reader = res.body!.getReader();
let lastUpdate = 0;
while (true)
{
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value.length;
if (totalBytes > 0 && this.onProgress)
{
const percent = (bytesReceived / totalBytes) * 100;
if (Date.now() - lastUpdate > 100)
{
this.onProgress({ progress: percent });
lastUpdate = Date.now();
}
}
if (this.signal?.aborted)
{
if (this.signal.reason === 'cancel')
{
console.log("Canceling Download and cleaning up files");
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta);
return;
}
console.log("Aborting Download: ", this.signal.reason);
break;
}
if (!stream.write(value))
{
await new Promise((resolve) => stream.once("drain", () => resolve(true)));
}
}
await new Promise((resolve, reject) =>
{
stream.end(() => resolve(undefined));
stream.on("error", reject);
});
}
await move(this.tmpPath, this.downloadPath, { overwrite: true });
if (await fs.exists(this.tmpPath))
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta);
return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name));
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@ export interface GameCardParams
onFocus?: GameCardFocusHandler;
onBlur?: (id: string) => void;
clickFocuses?: boolean;
previewClassName?: string;
}
export default function CardElement (data: GameCardParams & InteractParams)
@ -53,7 +54,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
role="button"
ref={ref}
style={{
scrollSnapAlign: "center"
scrollSnapAlign: isPointer ? "center" : "none"
}}
onFocus={focusSelf}
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
@ -74,7 +75,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
classNames({ "h-full": typeof data.preview === "string" })
)}>
{typeof data.preview === "string" ? (
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
<img draggable={false} className={classNames("object-cover w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}

View file

@ -2,10 +2,8 @@ import { RPC_URL } from "@/shared/constants";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation";
import { GameCardFocusHandler } from "./CardElement";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import queries from "../scripts/queries";
import { getCollectionsQuery } from "@queries/romm";
export default function CollectionList (data: {
id: string,
@ -17,12 +15,11 @@ export default function CollectionList (data: {
})
{
const navigate = useNavigate();
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
const { data: collections } = useSuspenseQuery(getCollectionsQuery());
const handleDefaultSelect = (id: string) =>
{
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
navigate({ to: `/collection/${id}` });
};
return (
@ -36,7 +33,7 @@ export default function CollectionList (data: {
id: String(g.id),
title: g.name,
focusKey: `collection-${g.id}`,
subtitle: g.user__username,
subtitle: g.owner_username,
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">

View file

@ -10,6 +10,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
import { PopNavigateSource } from '../scripts/spatialNavigation';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './CardElement';
import { Router } from '..';
import { HandleGoBack } from '../scripts/utils';
export interface CollectionsDetailParams
{
@ -30,7 +32,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`,
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
const handleScroll: GameCardFocusHandler = (id, node, details) =>

View file

@ -1,6 +1,6 @@
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX, useContext, useEffect } from "react";
import { JSX, useContext, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { X } from "lucide-react";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
@ -67,21 +67,61 @@ export interface DialogEntry
shortcuts?: Shortcut[];
}
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; })
{
const [open, setOpen] = useState(false);
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
const dialog = <ContextDialog id={id} open={open} close={() =>
{
setOpen(false);
data.onClose?.();
}} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}>
{data.content}
</ContextDialog>;
return {
dialog,
open,
setOpen: (value: boolean, sourceFocusKey?: string) =>
{
if (value === open) return;
if (value)
{
setOpen(true);
setSourceFocusKey(sourceFocusKey);
} else
{
setOpen(false);
if (sourceFocusKey)
{
setFocus(sourceFocusKey);
}
}
}
};
}
export function ContextDialog (data: {
id: string,
children: any | any[],
open: boolean,
close: () => void;
close: (open: boolean) => void;
className?: string;
preferredChildFocusKey?: string;
sourceFocusKey?: string;
})
{
const { ref, focusKey, focusSelf } = useFocusable({
focusable: data.open,
focusKey: `${data.id}-context-dialog`,
isFocusBoundary: true,
saveLastFocusedChild: !data.preferredChildFocusKey,
preferredChildFocusKey: data.preferredChildFocusKey
});
const handleClose = () =>
{
data.close(false);
};
useEffect(() =>
{
if (data.open)
@ -93,22 +133,16 @@ export function ContextDialog (data: {
useShortcuts(focusKey, () => data.open ? [{
label: "Close",
button: GamePadButtonCode.B,
action: () =>
{
data.close();
}
action: handleClose
}] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
}
onClick={() =>
{
if (data.open) data.close();
}}>
onClick={handleClose}>
<FocusContext value={focusKey}>
<ContextDialogContext value={{ id: data.id, close: data.close }} >
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
<div
className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",

View file

@ -12,9 +12,9 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
import SvgIcon from "./SvgIcon";
import { Button } from "./options/Button";
import toast from "react-hot-toast";
import queries from "../scripts/queries";
import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads";
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
function List (data: {
id: string,
@ -113,7 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
const { refetchFiles } = useContext(FilePickerContext);
const [name, setName] = useState<string | undefined>();
const createMutation = useMutation({
...queries.system.createFolderMutation(data.id),
...createFolderMutation(data.id),
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
onSuccess: (d, v, r, cx) =>
{
@ -228,8 +228,8 @@ export default function FilePicker (data: {
{
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery);
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
const fullPath = files ? path.join(files.parentPath, files.name) : '';
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];

View file

@ -1,17 +1,19 @@
import
{
FocusContext,
setFocus,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import { twMerge } from "tailwind-merge";
import { useEffect } from "react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
function FilterCat (
data: {
id: string;
children?: any;
active: boolean;
hasFocusedPeer: boolean;
} & FilterOption & FocusParams,
)
{
@ -26,9 +28,10 @@ function FilterCat (
aria-selected={data.active}
ref={ref}
onClick={focusSelf}
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"}
>
{data.children ?? data.label}
{data.icon ? <><div className="sm:portrait:px-2">{data.icon}</div><div className="sm:portrait:hidden md:inline">{data.children ?? data.label}</div></> : <div>{data.children ?? data.label}</div>}
</li>
);
}
@ -39,6 +42,8 @@ export function FilterUI (data: {
setSelected: (id: string) => void;
containerClassName?: string;
className?: string;
rootFocusKey?: string;
showShortcuts?: boolean;
})
{
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
@ -50,29 +55,72 @@ export function FilterUI (data: {
trackChildren: true
});
if (data.rootFocusKey)
{
useShortcuts(data.rootFocusKey, () => [
{
action: (e) =>
{
const filterKeys = Object.keys(data.options);
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f].selected));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
const newFilter = filterKeys[selectedFilterIndex];
if (!data.options[newFilter].selected)
{
data.setSelected(newFilter);
}
},
button: GamePadButtonCode.R1
},
{
action: (e) =>
{
const filterKeys = Object.keys(data.options);
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f as any].selected));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
const newFilter = filterKeys[selectedFilterIndex];
if (!data.options[newFilter].selected)
data.setSelected(newFilter);
},
button: GamePadButtonCode.L1
}], [data.options]);
}
useEffect(() =>
{
if (hasFocusedChild)
{
setFocus(`${data.id}-${defaultFocus}`);
}
}, [hasFocusedChild, defaultFocus, data.id]);
return (
<div
ref={ref}
className={data.containerClassName}
style={{ viewTransitionName: `filter-${data.id}` }}
>
<FocusContext.Provider value={focusKey}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
<li className=" flex px-4 items-center justify-center rounded-full">
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>
</li>}
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
hasFocusedPeer={hasFocusedChild}
id={`${data.id}-${id}`}
key={id}
onFocus={() => data.setSelected(id)}
onFocus={() =>
{
if (!option.selected)
data.setSelected(id);
}}
active={option.selected}
{...option}
/>
))}
<li className="flex px-4 items-center justify-center rounded-full">
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className="flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_r1_outline" />
</li>
</li>}
</ul>
</FocusContext.Provider>
</div>

View file

@ -42,7 +42,6 @@ export default function FocusDots (data: {
scrollElement?: RefObject<HTMLElement | null>;
})
{
const focusedKey = useGlobalFocus();
let elements = useMemo(() =>
{
@ -62,7 +61,7 @@ export default function FocusDots (data: {
return childrenArray.map((c, i) =>
{
return <ScrollDot parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
return <ScrollDot key={i} parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
});
} else
{

View file

@ -1,18 +1,15 @@
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
import CardElement from "./CardElement";
import { SaveSource } from "../scripts/spatialNavigation";
import { Router } from "..";
import { HardDrive } from "lucide-react";
import { FileQuestion, HardDrive, Store } from "lucide-react";
import { JSX } from "react";
import { FOCUS_KEYS } from "../scripts/types";
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams)
{
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
};
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
@ -27,7 +24,26 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
previewUrl.searchParams.set('width', "640");
const badges: JSX.Element[] = [];
if (data.game.id.source === 'local')
if (data.showSource)
{
switch (data.game.id.source)
{
case "local":
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
break;
case "romm":
badges.push(<img className="sm:size-4 md:size-8 m-1 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`} />);
break;
case "store":
badges.push(<Store className="sm:size-4 md:size-8 m-1" />);
break;
default:
badges.push(<FileQuestion className="sm:size-4 md:size-8 m-1" />);
break;
}
} else if (data.game.id.source === 'local')
{
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
}
@ -39,7 +55,9 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
preview={previewUrl.href}
title={data.game.name ?? ""}
subtitle={subtitle}
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""}
previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""}
index={data.index}
id={`game-${data.game.id.source}-${data.game.id.id}`}
/>;

View file

@ -2,13 +2,12 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
import { useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
import { HardDrive } from "lucide-react";
import { FileQuestion, HardDrive, Store } from "lucide-react";
import { JSX, useContext } from "react";
import { GameCardFocusHandler } from "./CardElement";
import { useLocalSetting } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import queries from "../scripts/queries";
import { allGamesQuery } from "@queries/romm";
export interface GameListParams
{
@ -25,7 +24,7 @@ export interface GameListParams
export function GameList (data: GameListParams)
{
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
const games = useSuspenseQuery(allGamesQuery(data.filters));
const navigator = useNavigate();
const blur = useLocalSetting('backgroundBlur');
const backgroundContext = useContext(AnimatedBackgroundContext);
@ -51,8 +50,7 @@ export function GameList (data: GameListParams)
function handleDefaultSelect (g: FrontEndGameType)
{
SaveSource('details', { search: { focus: g.slug ?? `game-${g.id}` } });
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } });
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
};
return (
@ -74,6 +72,7 @@ export function GameList (data: GameListParams)
{
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
}
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
previewUrl.searchParams.delete('ts');
previewUrl.searchParams.set('width', "16");

View file

@ -24,10 +24,10 @@ import { RoundButton } from "./RoundButton";
import { useQuery } from "@tanstack/react-query";
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
import { RPC_URL } from "../../shared/constants";
import { JSX, useEffect, useRef } from "react";
import { SaveSource } from "../scripts/spatialNavigation";
import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react";
import { systemApi } from "../scripts/clientApi";
import { Router } from "..";
import { useStickyDataAttr } from "../scripts/utils";
function HeaderAvatar (data: {
id: string;
@ -240,8 +240,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
],
action: () =>
{
SaveSource('settings');
Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
},
status: user.data ? "status-success" : 'status-error',
type: 'secondary'
@ -284,15 +283,19 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
</div>;
}
export function HeaderUI (data: {
interface HeaderUIParams
{
buttons?: HeaderButton[];
accounts?: HeaderAccount[];
buttonElements?: JSX.Element[] | JSX.Element;
title?: JSX.Element;
preferredChildFocusKey?: string;
})
focusable?: boolean;
}
export function HeaderUI (data: HeaderUIParams)
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
return (
<FocusContext.Provider value={focusKey}>
<header
@ -307,3 +310,18 @@ export function HeaderUI (data: {
</FocusContext.Provider>
);
}
export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
{
const [isStuck, setIsStuck] = useState(false);
const headerRef = useRef(null);
const sentinelRef = useRef(null);
useStickyDataAttr(headerRef, sentinelRef, data.ref, setIsStuck);
return <>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
<HeaderUI focusable={!isStuck} {...data} />
</div>
</>;
}

View file

@ -1,8 +1,9 @@
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FOCUS_KEYS } from "../scripts/types";
import { useIntersectionObserver } from "usehooks-ts";
import { FrontEndId } from "@/shared/constants";
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
{
const handleAction = (e?: Event) =>
{

View file

@ -1,6 +1,6 @@
import { Notification, RPC_URL } from "@/shared/constants";
import { useEffect } from "react";
import toast from "react-hot-toast";
import toast, { ToastOptions } from "react-hot-toast";
export default function Notifications (data: {})
{
@ -10,15 +10,16 @@ export default function Notifications (data: {})
es.addEventListener('notification', (e) =>
{
const notification = JSON.parse(e.data) as Notification;
const options: ToastOptions = { removeDelay: notification.duration };
if (notification.type === 'error')
{
toast.error(notification.message);
toast.error(notification.message, options);
} else if (notification.type === 'success')
{
toast.success(notification.message);
toast.success(notification.message, options);
} else
{
toast.custom(notification.message);
toast.custom(notification.message, options);
}
});

View file

@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
import { JSX, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { GameCardFocusHandler } from "./CardElement";
@ -37,8 +36,7 @@ export function PlatformsList (data: {
const handleDefaultSelect = (source: string, id: string) =>
{
SaveSource('game-list');
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
navigate({ to: `/platform/${source}/${id}` });
};
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())

View file

@ -1,12 +1,13 @@
import { RPC_URL } from "@/shared/constants";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { useEffect, useRef, useState } from "react";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import FocusDots from "./FocusDots";
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
import { Fullscreen } from "lucide-react";
import Carousel from "./Carousel";
import { ContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { twMerge } from "tailwind-merge";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
{
@ -26,7 +27,43 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
</div>;
}
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
function Preview (data: { id: string; screenshots?: string[]; preview: number; setPreview: Dispatch<SetStateAction<number | undefined>>; })
{
const { ref, focusKey } = useFocusable({ focusKey: data.id });
useShortcuts(focusKey, () => [
{
button: GamePadButtonCode.Left,
label: "Left",
action: () =>
{
if (data.preview === undefined || !data.screenshots) return;
data.setPreview(p =>
{
if (!data.screenshots) return p;
return (data.screenshots.length + (p ?? 0) - 1) % data.screenshots.length;
});
}
},
{
button: GamePadButtonCode.Right,
label: "Right",
action: () =>
{
if (data.preview === undefined || !data.screenshots) return;
data.setPreview(p =>
{
if (!data.screenshots) return p;
return (p ?? 0 + 1) % data.screenshots.length;
});
}
}
], [data.preview, focusKey, data.screenshots?.length ?? 0]);
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
}
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)
{
const [preview, setPreview] = useState<number | undefined>(undefined);
const scrollRef = useRef<HTMLDivElement>(null);
@ -41,9 +78,10 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
useEffect(() =>
{
if ((focused || hasFocusedChild) && scrollRef.current)
if ((focused || hasFocusedChild) && scrollRef.current && data.screenshots)
{
const closest = findClosestElementToCenter(scrollRef.current);
if (!closest) return;
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
setFocus(`screenshot-${closestIndex}`);
}
@ -54,6 +92,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
const center = element.scrollLeft + element.clientWidth / 2;
const children = Array.from(element.children) as HTMLElement[];
if (children.length <= 0) return undefined;
// find child closest to center
return children.reduce((closest, child) =>
@ -78,7 +117,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
const handleScroll = (dir: number, element: HTMLDivElement) =>
{
const current = findClosestElementToCenter(element);
if (!current) return;
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
if (!next) return;
@ -89,42 +128,21 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
});
};
useShortcuts(`screenshots-context-dialog`, () => [
{
button: GamePadButtonCode.Left,
label: "Left",
action: () =>
{
if (preview === undefined) return;
setPreview((data.screenshots.length + preview - 1) % data.screenshots.length);
}
},
{
button: GamePadButtonCode.Right,
label: "Right",
action: () =>
{
if (preview === undefined) return;
setPreview((preview + 1) % data.screenshots.length);
}
}
], [preview, focusKey]);
useDragScroll(scrollRef);
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
return <div ref={ref} className={twMerge("flex flex-col w-full z-0 min-h-0", data.className)}>
<FocusContext value={focusKey}>
<Carousel scrollHandler={handleScroll} scrollRef={scrollRef} rootClassName="h-full" className="flex gap-6 px-16 py-2 overflow-x-scroll no-scrollbar justify-center-safe h-full" >
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />)}
{data.screenshots?.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />) ?? <div className="skeleton w-32 h-32"></div>}
</Carousel>
<FocusDots scrollElement={scrollRef} />
</FocusContext>
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
{
setFocus(`screenshot-${preview}`);
setFocus(`screenshot-${preview}`, { instant: true });
setPreview(undefined);
}} open={true}>
<img draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots[preview]}`} loading="lazy" />
<Preview id="screenshot-preview" screenshots={data.screenshots} preview={preview} setPreview={setPreview} />
</ContextDialog>}
</div>;
}

View file

@ -0,0 +1,50 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
export interface StatEntry
{
icon?: JSX.Element,
label: string | JSX.Element,
content: string | JSX.Element | string[];
}
function Label (data: { id: string, label: string | JSX.Element; })
{
return <div className="font-semibold focused:text-accent">{data.label}:</div>;
}
export default function StatList (data: {
id: string;
stats: StatEntry[];
elementClassName?: string;
focusable?: boolean;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
focusable: data.focusable,
onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current, details)
});
return <ul ref={ref} className="grid md:grid-cols-[8rem_1fr] sm:px-8 md:px-16 py-4 gap-2 focused:border-y focused:border-dashed focused:border-base-content/40">
<FocusContext value={focusKey}>
{data.stats.map((s, i) =>
{
let content: any = undefined;
if (s.content instanceof Array)
{
content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-full bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>;
} else
{
content = <div key={`label-element-${i}`} className={twMerge("flex gap-2 rounded-full bg-base-200 px-3 py-1", data.elementClassName)}>{s.icon}{s.content}</div>;
}
const element = <>
<Label id={`${data.id}-label-${i}`} key={`label-${i}`} label={s.label} />
{content}
</>;
return element;
})}
</FocusContext>
</ul>;
}

View file

@ -0,0 +1,35 @@
import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/constants";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Medal } from "lucide-react";
function Achievement (data: { index: number, achievement: FrontEndGameTypeDetailedAchievement; } & FocusParams)
{
const { ref, focusKey } = useFocusable({ focusKey: `achievement-${data.index}`, onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current, details) });
return <div ref={ref} className="flex focusable focusable-primary gap-4 p-4 bg-base-300 rounded-3xl items-center scroll-mb-16 scroll-mt-32">
<div data-unlocked={!!data.achievement.date} data-hardcore={!!data.achievement.date_hardcode} className="data-[unlocked=true]:ring-4 aspect-square data-[unlocked=true]:ring-offset-4 ring-accent ring-offset-warning rounded-2xl overflow-hidden">
<img className="scale-110" src={data.achievement.badge_url} />
</div>
<div className="flex gap-2 sm:flex-col md:flex-row grow justify-between sm:items-start md:items-center">
<div>
<div className="flex gap-2">
{data.achievement.type === 'win_condition' && <Medal />}
<p className="font-semibold">{data.achievement.title}</p>
</div>
<p className="text-base-content/60">{data.achievement.description}</p>
</div>
{!!data.achievement.date && <div className="bg-base-100 rounded-3xl px-4 p-1">{data.achievement.date.toDateString()}</div>}
</div>
</div>;
}
export default function Achievements (data: { game: FrontEndGameTypeDetailed; })
{
const handleFocus = (key: string, node: HTMLElement, details: any) =>
{
node.scrollIntoView({ behavior: details?.instant ? 'instant' : 'smooth', block: 'nearest' });
};
return <div className="grid sm:grid-cols-1 md:grid-cols-3 px-4 gap-2">
{data.game.achievements?.entires.map((a, i) => <Achievement index={i} onFocus={handleFocus} key={i} achievement={a} />)}
</div>;
}

View file

@ -1,14 +1,14 @@
import { useState } from "react";
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
import { useMutation } from "@tanstack/react-query";
import queries from "@/mainview/scripts/queries";
import { changeDownloadsMutation } from "@queries/settings";
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
{
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const setSettingMutation = useMutation({
...queries.settings.changeDownloadsMutation,
...changeDownloadsMutation,
onSuccess: (d, v, r, cx) =>
{
setDirty(r !== localValue);

View file

@ -8,7 +8,7 @@ import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
import { ContextDialog } from "../ContextDialog";
import FilePicker from "../FilePicker";
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
import queries from "@/mainview/scripts/queries";
import { getSettingQuery, setSettingMutation } from "@queries/settings";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
@ -33,7 +33,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const setMutation = useMutation({
...queries.settings.setSettingMutation(data.id),
...setSettingMutation(data.id),
onSuccess: (d, v, r, cx) =>
{
setDirty(r !== localValue);
@ -63,7 +63,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
})
{
const [isBrowsing, setIsBrowsing] = useState(false);
const { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id));
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const changed = defaultValue !== data.localValue;
useEffect(() =>

View file

@ -3,7 +3,7 @@ import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import queries from "@/mainview/scripts/queries";
import { getSettingQuery, setSettingMutation } from "@queries/settings";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
@ -20,8 +20,8 @@ export function SettingsOption (data: {
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
useQuery(queries.settings.getSettingQuery(data.id));
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
useQuery(getSettingQuery(data.id));
const setMutation = useMutation(setSettingMutation(data.id));
const handleSave = useCallback(() =>
{

View file

@ -69,7 +69,7 @@ export function EmulatorsSection (data: {
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
}} />
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
</Carousel>
</section>

View file

@ -1,50 +1,58 @@
import { useRef } from "react";
import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Gamepad2, Star } from "lucide-react";
import { useDragScroll } from "@/mainview/scripts/utils";
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
import FocusDots from "../FocusDots";
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
import FrontEndGameCard from "../FrontEndGameCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import Carousel from "../Carousel";
import { twMerge } from "tailwind-merge";
export function GamesSection ({ games, onSelect, onFocus }: {
export function GamesSection (data: {
games?: FrontEndGameType[];
onSelect?: (id: FrontEndId, focusKey: string) => void;
className?: string;
showSources?: boolean;
ref?: Ref<any>;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
const { ref, focusKey, focused, focusSelf } = useFocusable({
focusKey: FOCUS_KEYS.GAME_SECTION,
trackChildren: true,
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
});
const containerRef = useRef(null);
useDragScroll(containerRef);
useEffect(() =>
{
if (focused)
focusSelf();
}, [!!data.games]);
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-6 py-3 select-none">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Featured Games
</h2>
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
</div>
<section ref={(r) =>
{
ref.current = r;
if (data.ref instanceof Function) data.ref(r);
else if (data.ref) data.ref.current = r;
}} className={twMerge("select-none", data.className)}>
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
{games?.map((g, i) => <FrontEndGameCard
{data.games?.map((g, i) => <FrontEndGameCard
showSource={data.showSources}
key={g.id.id}
game={g}
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))}
onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })}
index={i} />) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full" />)}
</Carousel>
</section>
<FocusDots elements={games?.map(e => FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} />
<FocusDots elements={data.games?.map(e => FOCUS_KEYS.GAME_CARD(e.id)) ?? []} />
</FocusContext.Provider>
);
}

View file

@ -1,5 +1,6 @@
import queries from "@/mainview/scripts/queries";
import { storeGetStatsQuery } from "@queries/store";
import { useQuery } from "@tanstack/react-query";
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
@ -15,7 +16,7 @@ export function StatsSection ({
}: StatsSectionProps)
{
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
const { data: stats } = useQuery(storeGetStatsQuery);
return (
<section className="px-6 pt-3 pb-4">

View file

@ -5,8 +5,18 @@ import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { ChevronRight, EllipsisVertical, HardDrive } from "lucide-react";
import { ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Store } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX } from "react";
export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />,
custom: <FileQuestion />,
flatpak: FlatpackIcon,
winget: <Package />,
scoop: <IceCream2 />
};
export function StoreEmulatorCard (data: {
id: string;
@ -35,7 +45,7 @@ export function StoreEmulatorCard (data: {
ref={ref}
role="button"
tabIndex={0}
data-installed={data.emulator.exists ? true : undefined}
data-installed={!!data.emulator.validSource}
onClick={isTouch ? handleSelect : undefined}
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
>
@ -44,14 +54,14 @@ export function StoreEmulatorCard (data: {
<div className="flex gap-2">
<div className="flex items-start">
<div
data-installed={data.emulator.exists}
data-installed={!!data.emulator.validSource}
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
>
<img draggable={false} src={data.emulator.logo}></img>
</div>
</div>
<div>
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
<p data-installed={!!data.emulator.validSource} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
<ul className="flex flex-wrap gap-1">
{data.emulator.systems.map(({ id, name, icon }) =>
{
@ -66,10 +76,12 @@ export function StoreEmulatorCard (data: {
</div>
<div className="flex gap-0.5 mt-1 h-10 items-center">
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
{!!data.emulator.validSource && <div className="tooltip" data-tip={data.emulator.validSource.type}>
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content">
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
</div>
</div>}
{<div className="tooltip" data-tip="Game Count">
{data.emulator.gameCount > 0 && <div className="tooltip" data-tip="Game Count">
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
</div>}
{isMouse && <>

View file

@ -28,6 +28,7 @@ window.addEventListener('message', (e) =>
});
window.EJS_threads = true;
window.EJS_player = "#game";
window.EJS_lightgun = false;
window.EJS_startOnLoaded = true;

View file

@ -464,7 +464,7 @@ const assets = new Set<string>([
]);
// Store basePath resolved from Vite config
const BASE_PATH = "./";
const BASE_PATH = "/";
/**

View file

@ -393,6 +393,17 @@ body {
width: 100%;
}
html:active-view-transition-type(slide-up) {
&::view-transition-old(root) {
animation: fade-out 300ms ease-in forwards;
}
&::view-transition-new(root) {
animation: slide-up 300ms ease-in-out forwards;
}
}
html:active-view-transition-type(zoom-in) {
&::view-transition-old(root) {
@ -449,6 +460,18 @@ body {
}
}
@keyframes slide-up {
from {
transform: translateY(10vh);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes zoom-in-fade-in {
from {
scale: 105%;

View file

@ -17,6 +17,7 @@ import "./scripts/spatialNavigation";
import NotFound from "./components/NotFound";
import Error from "./components/Error";
import serviceWorker from './scripts/serviceWorker?worker&url';
import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation";
if ('serviceWorker' in navigator)
{
@ -44,10 +45,42 @@ export const Router = createRouter({
history: hashHistory,
defaultPreload: "intent",
context: { queryClient },
scrollRestoration: false,
scrollRestoration: true,
defaultNotFoundComponent: NotFound,
defaultPendingMs: 300,
defaultErrorComponent: Error
defaultErrorComponent: Error,
defaultViewTransition: {
types ({ fromLocation, toLocation })
{
let direction = 'in';
if (fromLocation)
{
const fromIndex = fromLocation.state.__TSR_index;
const toIndex = toLocation.state.__TSR_index;
direction = fromIndex > toIndex ? 'in' : 'out';
}
return [`zoom-${direction}`];
},
}
});
const focusMap = new Map<number, string>();
Router.history.subscribe((op) =>
{
if (op.action.type === 'PUSH')
{
focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey());
} else if (op.action.type === 'BACK')
{
if (focusMap.has(op.location.state.__TSR_index))
{
setFocus(focusMap.get(op.location.state.__TSR_index)!);
focusMap.delete(op.location.state.__TSR_index);
}
}
});
// Register things for typesafety

View file

@ -5,7 +5,7 @@ import { DefaultRommStaleTime } from '@shared/constants';
import { useQuery } from '@tanstack/react-query';
import { useContext } from 'react';
import { AnimatedBackgroundContext } from '../scripts/contexts';
import queries from '../scripts/queries';
import { getCollectionQuery } from '@queries/romm';
export const Route = createFileRoute('/collection/$id')({
component: RouteComponent,
@ -18,7 +18,7 @@ export const Route = createFileRoute('/collection/$id')({
function RouteComponent ()
{
const { id } = Route.useParams();
const { data: collection } = useQuery(queries.romm.getCollectionQuery(Number(id)));
const { data: collection } = useQuery(getCollectionQuery(Number(id)));
const animatedBgContext = useContext(AnimatedBackgroundContext);
return (

View file

@ -14,13 +14,13 @@ import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge';
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
import { RoundButton } from '../components/RoundButton';
import queries from '../scripts/queries';
import { gameQuery } from '@queries/romm';
export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent,
loader: async (ctx) =>
{
const data = await ctx.context.queryClient.fetchQuery(queries.romm.gameQuery(ctx.params.source, ctx.params.id));
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
return { data };
},
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
@ -133,7 +133,7 @@ function RouteComponent ()
function HandleGoBack ()
{
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
Router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true });
}
useEventListener('message', e =>

View file

@ -1,37 +1,53 @@
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
import { CommandEntry, RPC_URL } from "@shared/constants";
import { twMerge } from "tailwind-merge";
import { JSX, RefObject, useEffect, useRef, useState } from "react";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { Clock, CloudDownload, Download, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
import { Calendar, Clock, CloudDownload, Download, EllipsisVertical, Folder, Gamepad2, HardDrive, Image, Info, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
import { HeaderUI } from "../../components/Header";
import prettyBytes from 'pretty-bytes';
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
import { useFocusEventListener } from "../../scripts/spatialNavigation";
import { AnimatedBackground } from "../../components/AnimatedBackground";
import { rommApi } from "../../scripts/clientApi";
import toast from "react-hot-toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Router } from "../..";
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog";
import Shortcuts from "../../components/Shortcuts";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import queries from "@/mainview/scripts/queries";
import Screenshots from "@/mainview/components/Screenshots";
import { useStickyDataAttr } from "@/mainview/scripts/utils";
import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
import useActiveControl from "@/mainview/scripts/gamepads";
import { FilterUI } from "@/mainview/components/Filters";
import StatList, { StatEntry } from "@/mainview/components/StatList";
import { useIntersectionObserver, useLocalStorage } from "usehooks-ts";
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
import { zodValidator } from "@tanstack/zod-adapter";
import z from "zod";
import Achievements from "@/mainview/components/game/Achievements";
import { getErrorMessage } from "react-error-boundary";
import { GameDetailsContext } from "@/mainview/scripts/contexts";
import { rommApi } from "@/mainview/scripts/clientApi";
import { deleteGameMutation, gameQuery, gamesRecommendedBasedOnGameQuery, installMutation, playMutation } from "@queries/romm";
import { GamesSection } from "@/mainview/components/store/GamesSection";
export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) =>
{
const data = await context.queryClient.fetchQuery(queries.romm.gameQuery(params.source, params.id));
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
return { data };
},
component: GameDetailsUI,
pendingComponent: GameDetailsUIPending,
errorComponent: Error
errorComponent: Error,
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
});
function useDetailsSection ()
{
return useLocalStorage('details-section', 'screenshots');
}
function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
@ -51,7 +67,7 @@ function Error (data: ErrorComponentProps)
<HeaderUI />
</div>
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {JSON.stringify(data.error, null, 3)}</div>
</div>
<div className="bg-base-200">
@ -139,35 +155,52 @@ function GameDetailsUIPending ()
</AnimatedBackground>;
}
function HandleGoBack ()
function MoreDetails (data: {})
{
const { to, search } = PopSource('details');
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
const { data: game } = Route.useLoaderData();
const [details] = useDetailsSection();
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: "game-more-details-section",
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start', behavior: 'smooth' })(focusKey, ref.current, d),
trackChildren: true
});
return <div ref={ref} className="scroll-mt-[15vh]">
<FocusContext value={focusKey}>
<Divider rootFocusKey={focusKey} showShortcuts={hasFocusedChild} />
<div className="bg-base-200 py-12 min-h-[80vh]">
<div key={details} className="h-full animate-slide-up">
{details === 'screenshots' && <div className="h-[60vh]"><Screenshots screenshots={game.paths_screenshots} /></div>}
{details === 'stats' && <Stats />}
{details === 'achievements' && <Achievements game={game} />}
</div>
</div>
</FocusContext>
</div>;
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>; })
{
const { data: game } = Route.useLoaderData();
const { ref, focusKey } = useFocusable({
focusKey: 'main-details', onFocus: () =>
{
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
},
focusKey: 'main-details',
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d),
preferredChildFocusKey: "play-btn",
saveLastFocusedChild: false
});
const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover ?? ''}`);
const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${game?.path_platform_cover ?? ''}`);
platformCoverImg.searchParams.set("width", "64");
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
const gameCoverImg = game?.path_cover ? `${RPC_URL(__HOST__)}${game?.path_cover}` : undefined;
let fileSizeIcon: JSX.Element | undefined;
if (!data.game)
if (!game)
{
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
} else if (data.game.missing)
} else if (game.missing)
{
fileSizeIcon = <TriangleAlert />;
} else if (data.game.local)
} else if (game.local)
{
fileSizeIcon = <HardDrive />;
} else
@ -186,23 +219,23 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
</div>
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
<Detail icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</Detail>
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
<div className={classNames({ "text-error": data.game.missing })}>
<div className="tooltip" data-tip={data.game.path_fs}>
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
<Detail icon={<Clock />} >{game?.last_played ? new Date(game.last_played).toDateString() : "Never"}</Detail>
{!!game && (game.fs_size_bytes !== null || game.missing) &&
<div className={classNames({ "text-error": game.missing })}>
<div className="tooltip" data-tip={game.path_fs}>
<Detail icon={fileSizeIcon} >{game.missing ? 'Missing' : prettyBytes(game.fs_size_bytes!)}</Detail>
</div>
</div>}
<Detail icon={<img className="size-6" src={platformCoverImg.href}></img>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
<Detail icon={<img className="size-6" src={platformCoverImg.href}></img>} >{game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
<Detail icon={
<Store />
} >
{data.game?.source ?? data.game?.id.source}
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
{game?.source ?? game?.id.source}
{game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div>
<div className="md:hidden divider divider-vertical m-0"></div>
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
{game?.summary ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-full"></div>
@ -211,107 +244,90 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
<div className="skeleton h-4 w-[80%]"></div>
</div>}
</div>
{!!data.game && <ActionButtons key="actions" game={data.game} />}
{!!game && <ActionButtons key="actions" />}
</div>
</section>
</FocusContext>
</main>;
}
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
function AchievementsInfo (data: InteractParams)
{
if (!data.game.achievements)
const { data: game } = Route.useLoaderData();
if (!game.achievements)
{
return false;
}
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
<div className="flex flex-col gap-2 items-center text-2xl">
<div className="flex flex-row">
return <ActionButton key="achievements" square tooltip="Achievements" type="base" className="sm:rounded-2xl md:rounded-3xl" id="achievements" onAction={data.onAction} >
<div className="flex flex-col sm:gap-0 md:gap-2 items-center sm:text-xl md:text-2xl sm:px-4 sm:py-2 md:p-0">
<div className="flex flex-row items-center gap-1">
<Trophy />
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
{`${game.achievements.unlocked}/${game.achievements.total}`}
</div>
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
<progress className="progress progress-secondary w-full" value={game.achievements.unlocked / game.achievements.total} max="1"></progress>
</div>
</ActionButton>;
}
function MainActions (data: { game: FrontEndGameTypeDetailed; })
function MainActions ()
{
const { data } = Route.useLoaderData();
const { source, id } = Route.useParams();
const installMutation = useMutation({
mutationKey: ['install'],
mutationFn: async () =>
const installMut = useMutation(installMutation(source, id));
const playMut = useMutation({
...playMutation, onError (error)
{
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
if (error) throw error;
}
});
const playMutation = useMutation({
mutationKey: ['play'],
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
if (error)
{
if (error.value.message)
{
toast.error(error.value.message);
}
throw error;
};
}
toast.error(error.message);
},
});
const ws = useRef<{ send: (data: string) => void; }>(undefined);
const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined);
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
const [preferredCommand, setPreferredCommand] = useLocalStorage<string | number | undefined>(`${data.source ?? data.id.source}-${data.source_id ?? data.id.id}-preferred-command`, undefined);
const queryClient = useQueryClient();
const validCommands = commands ? commands.filter(c => c.valid) : [];
const validDefaultCommand = commands?.find(c =>
{
if (!c.valid) return false;
if (preferredCommand && c.id !== preferredCommand) return false;
return true;
});
useEffect(() =>
{
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`);
const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
ws.current = sub.ws;
es.onmessage = ({ data }) =>
sub.subscribe((e) =>
{
const stats = JSON.parse(data) as GameInstallProgress;
setProgress(stats.progress);
setStatus(stats.status);
setDetails(stats.details);
setCommands(stats.commands);
setError(stats.error);
};
setStatus(e.data.status);
setProgress((e.data as any).progress);
setDetails((e.data as any).details);
setCommands((e.data as any).commands);
es.addEventListener('refresh', () =>
{
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
Router.navigate({ to: '/game/$source/$id', params: { id, source } });
});
es.addEventListener('error', (e) =>
{
if ((e as any).data)
if (e.data.status === 'refresh')
{
const stats = JSON.parse((e as any).data) as GameInstallProgress;
toast.error(stats.error);
setError(stats.error);
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true });
} else if (e.data.status === 'error')
{
const errorMessage = getErrorMessage(e.data.error);
if (!errorMessage) return;
toast.error(errorMessage);
setError(errorMessage);
}
});
es.onerror = (event) =>
return () =>
{
const error = (event as any).data?.error;
if (error)
{
toast.error(error);
setError(error);
}
sub.close();
ws.current = undefined;
};
return () => es.close();
}, [data.game.id]);
}, [data.id]);
let progressIcon: JSX.Element | undefined = undefined;
switch (status)
@ -319,29 +335,51 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
case 'download':
progressIcon = <Download />;
break;
case 'queued':
progressIcon = <Clock />;
break;
case 'extract':
progressIcon = <PackageOpen />;
break;
}
let mainButton: JSX.Element | undefined = undefined;
const showProgress = progress !== null && !!progressIcon;
useEffect(() =>
{
if (showProgress) return;
showInstallOptions(false);
}, [showProgress]);
const handlePlay = (cmd?: CommandEntry) =>
{
if (!cmd) return;
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
Router.navigate({ to: '/embedded/$source/$id', params: { source, id }, search: Object.fromEntries(params.entries()), replace: true });
} else
{
playMut.mutate({ source: data.id.source, id: data.id.id, command_id: cmd.id });
Router.navigate({ to: '/launcher/$source/$id', params: { source, id }, replace: true });
}
};
let mainButton: any | undefined = undefined;
if (status === 'installed')
{
mainButton = <ActionButton onAction={() =>
{
const firstValid = commands?.find(c => c.valid);
if (firstValid?.emulator === 'emulatorjs')
{
const params = new URLSearchParams(firstValid.command);
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
} else
{
playMutation.mutate();
SaveSource('launch');
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
}
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
key="primary"
type='primary'
id="mainAction"
>
<Play />
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
</ActionButton>
{validCommands.length > 1 &&
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
<EllipsisVertical />
</ActionButton>}</div>;
}
else if (error)
{
@ -354,8 +392,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
if (status === 'missing-emulator')
{
SaveSource('settings');
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/settings/directories' });
}
}}
id="mainAction">
@ -366,12 +403,12 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
mainButton = <ActionButton
key={status ?? 'unknown'}
disabled={installMutation.isPending}
disabled={installMut.isPending}
onAction={() =>
{
if (status === 'install')
{
installMutation.mutate();
installMut.mutate();
}
}}
tooltip={details ?? status}
@ -381,10 +418,41 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
</ActionButton>;
}
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map(c =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
action (ctx)
{
setPreferredCommand(c.id);
handlePlay(c);
},
};
return commands;
})} />,
preferredChildFocusKey: String(preferredCommand)
});
const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', {
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
});
return <div className="flex gap-2">
{mainButton}
<div className="divider divider-horizontal m-0"></div>
{progress !== null && !!progressIcon && <ActionButton key="progress" square tooltip={details} type="base" id="progress" >
{showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
<div className="flex flex-row">
{progressIcon}
@ -392,26 +460,34 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div>
</ActionButton>}
{installOptionsDialog}
{allCommandDialog}
</div>;
}
function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
function ActionButtons (data: {})
{
const [, setDetailsSection] = useDetailsSection();
const { data: game } = Route.useLoaderData();
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
const [hoverTextType, setHoverTextType] = useState<string>('accent');
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
const [open, setOpen] = useState(false);
const deleteMutation = useMutation({
...queries.romm.deleteGameMutation,
...deleteGameMutation(game.id),
onSuccess: () =>
{
location.reload();
console.log("Deleted");
},
onError (error)
{
toast.error(getErrorMessage(error) ?? "Error While Deleting");
}
});
const contextOptions: DialogEntry[] = [];
if (data.game.local)
if (game.local)
{
contextOptions.push({
id: 'delete',
@ -451,16 +527,20 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
return <div ref={ref} className="flex sm:gap-2 md:gap-4 sm:h-16 md:h-32 overflow-hidden p-2 items-center shrink-0">
<FocusContext value={focusKey}>
<MainActions game={data.game} />
<AchievementsInfo game={data.game} />
<MainActions />
<AchievementsInfo onAction={() =>
{
setDetailsSection("achievements");
if (game.achievements?.entires[0])
{
setFocus(game.achievements.entires[0].id);
}
}} />
<ActionButton tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
</ActionButton >
<ContextDialog id="settings-context" open={open} close={() =>
{
setOpen(false);
setFocus("settings");
}}>
<ContextDialog sourceFocusKey="settings" id="settings-context" open={open} close={setOpen}>
<ContextList options={contextOptions} />
</ContextDialog>
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
@ -496,7 +576,7 @@ function ActionButton (data: {
const styles = {
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
accent: "bg-accent text-accent-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
};
return (
@ -516,45 +596,135 @@ function ActionButton (data: {
);
}
export default function GameDetailsUI ()
function Stats ()
{
const { data } = Route.useLoaderData();
const stats: StatEntry[] = [];
if (data.path_fs)
stats.push({ label: "Location", content: data.path_fs, icon: <Folder /> });
if (data.companies)
stats.push({ label: "Companies", content: data.companies });
if (data.genres)
stats.push({ label: 'Genres', content: data.genres });
if (data.release_date)
stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: <Calendar /> });
if (data.emulators)
stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) });
return <StatList elementClassName="bg-base-300" stats={stats} />;
}
function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
{
const [details, setDetails] = useDetailsSection();
const { data: game } = Route.useLoaderData();
const { ref, focusKey } = useFocusable({
focusKey: "details-divider",
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d),
});
const detailFilter: Record<string, FilterOption> = {
stats: { label: "Stats", selected: details === 'stats', icon: <Info /> },
screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: <Image /> },
};
if (game.achievements)
{
detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: <Trophy /> };
}
return <div ref={ref} className="divider justify-center bg-linear-to-t from-base-200 to-base-100 h-fit py-0 m-0 scroll-mt-32">
<FocusContext value={focusKey}>
<FilterUI showShortcuts={data.showShortcuts} rootFocusKey={data.rootFocusKey} className="bg-base-200 drop-shadow-none z-20 gap-1" id="details-filter" options={detailFilter} setSelected={setDetails} />
</FocusContext>
</div>;
}
export default function GameDetailsUI ()
{
const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false);
const { data } = Route.useLoaderData();
const { focus } = Route.useSearch();
const [, setUpdate] = useState(0);
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
focusSelf();
if (focus)
{
setFocus(focus, { instant: true });
} else
{
focusSelf();
}
}, []);
useStickyDataAttr(headerRef, sentinelRef, ref);
const recommendedEmulators = data.emulators?.filter(e => e.store_exists);
const { ref: intersct } = useIntersectionObserver({
onChange: (isIntersecting, entry) =>
{
setRecommendedGamesVisible(isIntersecting);
}
});
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
<div className="z-10">
<FocusContext value={focusKey}>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="bg-base-200">
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
<GameDetailsContext value={{
update: () => setUpdate(v => v + 1)
}} >
<div className="z-10">
<FocusContext value={focusKey}>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<Details mainAreaRef={mainAreaRef} />
</div>
<MoreDetails />
<div className="relative bg-base-300">
{!!recommendedEmulators && recommendedEmulators.length > 0 && <EmulatorsSection
id={`${data.id.id}-recommended`}
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<h2 className="font-bold uppercase tracking-widest">
Related Emulators
</h2></>}
onFocus={scrollIntoViewHandler({ block: 'center' })}
onSelect={(id, focus) =>
{
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
}}
emulators={recommendedEmulators} />}
</div>
<div className="bg-base-100">
<div className="px-6 py-3">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Related Games
</h2>
</div>
<GamesSection ref={intersct} showSources onSelect={(id, focus) =>
{
Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } });
}} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} />
</div>
</div>
</FocusContext>
</div>
<footer className="fixed right-0 bottom-0 p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</GameDetailsContext>
</AnimatedBackground>
);
}

View file

@ -29,7 +29,6 @@ import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
import { FilterUI } from "../components/Filters";
import { AnimatedBackground } from "../components/AnimatedBackground";
import { GameList } from "../components/GameList";
import { SaveSource } from "../scripts/spatialNavigation";
import LoadingCardList from "../components/LoadingCardList";
import { AutoFocus } from "../components/AutoFocus";
import SaveScroll from "../components/SaveScroll";
@ -46,7 +45,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import { FrontEndId } from "@/shared/constants";
import Carousel from "../components/Carousel";
import queries from "../scripts/queries";
import { closeMutation } from "@queries/system";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@ -125,20 +124,17 @@ function HomeList (data: {
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { filter: data.selectedFilter } });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
};
const handleCollectionSelect = (id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: `/collection/${id}` });
};
const handlePlatformSelect = (source: string, id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: `/platform/${source}/${id}` });
};
let activeList: JSX.Element;
@ -224,7 +220,6 @@ function MainMenu ()
focusKey: `main-menu`,
trackChildren: true,
});
const navigate = useNavigate();
return (
<ul
ref={ref}
@ -233,13 +228,13 @@ function MainMenu ()
>
<FocusContext.Provider value={focusKey}>
<CircleIcon
action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })}
action={() => Router.navigate({ to: "/games" })}
icon={<Gamepad2 />}
label="Home"
type="secondary"
/>
<CircleIcon icon={<MessageSquare />} label="News" />
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
<CircleIcon type="info" icon={<Store />} action={() => Router.navigate({ to: "/store/tab" })} label="Shop" />
<CircleIcon icon={<Image />} label="Album" />
<CircleIcon
icon={<Gamepad2 />}
@ -248,8 +243,7 @@ function MainMenu ()
<CircleIcon
action={() =>
{
SaveSource('settings');
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/settings/accounts' });
}}
icon={<Settings />}
label="Settings"
@ -294,7 +288,7 @@ export default function ConsoleHomeUI ()
{
const { filter } = Route.useSearch();
const close = useMutation(queries.system.closeMutation);
const close = useMutation(closeMutation);
const { ref, focusKey } = useFocusable({
forceFocus: true,
@ -304,29 +298,7 @@ export default function ConsoleHomeUI ()
preferredChildFocusKey: `home-list`,
});
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } });
useShortcuts(focusKey, () => [
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
},
button: GamePadButtonCode.R1
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
},
button: GamePadButtonCode.L1
}], [filter]);
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
const { shortcuts } = useShortcutContext();
const headerButtons = [];
@ -342,6 +314,7 @@ export default function ConsoleHomeUI ()
</div>
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
<FilterUI
rootFocusKey={focusKey}
id="home"
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}

View file

@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts from '../components/Shortcuts';
import queries from '../scripts/queries';
import { gameQuery } from '@queries/romm';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
@ -18,12 +18,12 @@ function RouteComponent ()
{
function HandleGoBack ()
{
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
}
const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
const { data } = useQuery(queries.romm.gameQuery(source, id));
const { data } = useQuery(gameQuery(source, id));
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();

View file

@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useQuery } from "@tanstack/react-query";
import { RPC_URL } from "../../shared/constants";
import queries from "../scripts/queries";
import { platformQuery } from "@queries/romm";
export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent
@ -22,7 +22,7 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string;
function RouteComponent ()
{
const { source, id } = Route.useParams();
const { data: platform } = useQuery(queries.romm.platformQuery(source, id));
const { data: platform } = useQuery(platformQuery(source, id));
return (
<div className="w-full h-full">

View file

@ -1,5 +1,6 @@
import queries from '@/mainview/scripts/queries';
import { systemInfoQuery } from '@queries/system';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
@ -10,7 +11,7 @@ export const Route = createFileRoute('/settings/about')({
function RouteComponent ()
{
const { data: systemInfo } = useQuery(queries.system.systemInfoQuery);
const { data: systemInfo } = useQuery(systemInfoQuery);
return <table className="table">
<tbody>
<tr>

View file

@ -23,7 +23,8 @@ import QRCode from "react-qr-code";
import { useJobStatus } from "@/mainview/scripts/utils";
import { useInterval } from "usehooks-ts";
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
import queries from "@/mainview/scripts/queries";
import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings";
import { rommGetOptionsQuery, rommHasPasswordQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
@ -52,14 +53,14 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url:
function TwitchLogin ()
{
const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery);
const loginStatus = useQuery(twitchLoginVerificationQuery);
const loginMutation = useMutation({
...queries.settings.twitchLoginMutation,
...twitchLoginMutation,
onSuccess: () => loginStatus.refetch()
});
const logoutMutation = useMutation({ ...queries.settings.twitchLogoutMutation, onSuccess: () => loginStatus.refetch() });
const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() });
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
@ -84,13 +85,13 @@ function TwitchLogin ()
function LoginControls (data: { hasPassword: boolean; })
{
const user = useQuery(queries.romm.rommUserQuery());
const loginMutation = useMutation(queries.romm.rommQrLoginMutation);
const user = useQuery(rommUserQuery());
const loginMutation = useMutation(rommQrLoginMutation);
const { data: statusValue, wsRef } = useJobStatus('login-job');
const context = useSettingsFormContext({});
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({
...queries.romm.rommLogoutMutation,
...rommLogoutMutation,
onSuccess: async (d, v, r, c) =>
{
user.refetch();
@ -136,9 +137,9 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery);
const { data: hostname } = useQuery(queries.romm.rommHostnameQuery);
const { data: username } = useQuery(queries.romm.rommUsernameQuery);
const { data: hasPassword } = useQuery(rommHasPasswordQuery);
const { data: hostname } = useQuery(rommHostnameQuery);
const { data: username } = useQuery(rommUsernameQuery);
const loginForm = useSettingsForm({
defaultValues: {
@ -160,7 +161,7 @@ function RouteComponent ()
}
});
const rommOnline = useQuery(queries.romm.rommGetOptionsQuery());
const rommOnline = useQuery(rommGetOptionsQuery());
useEffect(() =>
{
@ -170,7 +171,7 @@ function RouteComponent ()
}
}, [focus]);
const loginMutation = useMutation(queries.romm.rommLoginMutation);
const loginMutation = useMutation(rommLoginMutation);
let indicator = "";
if (rommOnline.isError)

View file

@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
import { Block, createFileRoute } from '@tanstack/react-router';
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
import queries from '@/mainview/scripts/queries';
import { DownloadsDrive } from '@/shared/constants';
import prettyBytes from 'pretty-bytes';
import classNames from 'classnames';
@ -13,6 +12,7 @@ import { OptionSpace } from '@/mainview/components/options/OptionSpace';
import { Button } from '@/mainview/components/options/Button';
import { systemApi } from '@/mainview/scripts/clientApi';
import useActiveControl from '@/mainview/scripts/gamepads';
import { changeDownloadsMutation } from '@queries/settings';
export const Route = createFileRoute('/settings/directories')({
component: RouteComponent,
@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
focusKey: data.drive.device,
onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
});
const isMoving = useIsMutating(queries.settings.changeDownloadsMutation);
const isMoving = useIsMutating(changeDownloadsMutation);
const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
const usedPercent = usedWithoutDownlods / data.drive.size;
const usedPercentRaw = data.drive.used / data.drive.size;
const changeDownloads = useMutation({ ...queries.settings.changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
const shortcuts: Shortcut[] = [];
const valid = !data.drive.unusableReason && isMoving <= 0;
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);

View file

@ -14,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import FilePicker from '@/mainview/components/FilePicker';
import { dirname } from 'pathe';
import queries from '@/mainview/scripts/queries';
import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
@ -98,13 +98,13 @@ function EmulatorPath (data: { id: string; })
const [isSearching, setIsSearching] = useState(false);
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id));
const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) =>
const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id));
const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) =>
{
setLocalValue(v);
setDirty(false);
}));
const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id));
const deleteMutation = useMutation(customEmulatorDeleteMutation(data.id));
const handleSave = useCallback(() =>
{
@ -223,11 +223,11 @@ function EmulatorBadge (data: {
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
{
const { data: autoEmulators } = useQuery(queries.settings.autoEmulatorsQuery);
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.map(e => <EmulatorBadge key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.path?.path} exists={e.exists} emulator={e.name} />)}
{autoEmulators?.map(e => <EmulatorBadge key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.validSource?.binPath} exists={!!e.validSource} emulator={e.name} />)}
</FocusContext>
</div>;
}
@ -240,9 +240,9 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const { data: customEmulators } = useQuery(queries.settings.customEmulatorsQuery);
const { data: customEmulators } = useQuery(customEmulatorsQuery);
const addOverrideMutation = useMutation(queries.settings.customEmulatorAddMutation);
const addOverrideMutation = useMutation(customEmulatorAddMutation);
return <FocusContext value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">

View file

@ -8,7 +8,6 @@ import
Outlet,
createFileRoute,
useMatch,
useNavigate,
} from "@tanstack/react-router";
import { ViewTransitionOptions } from "@tanstack/router-core";
import classNames from "classnames";
@ -25,10 +24,10 @@ import { JSX, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import z from "zod";
import { SettingsSchema } from "../../../shared/constants";
import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import { HandleGoBack } from "@/mainview/scripts/utils";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
@ -48,21 +47,26 @@ function MenuItem (data: {
label: string;
})
{
const navigate = useNavigate();
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
const handleNonFocusSelect = () =>
{
const { to, search } = PopSource('settings');
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
if (data.return)
{
HandleGoBack();
} else if (!acitve)
{
Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
}
};
const { ref, focusSelf } = useFocusable({
focusKey: `menu-item-${data.route}`,
forceFocus: !!acitve,
onFocus: () =>
{
if (data.focusSelect)
if (data.focusSelect && !acitve)
{
navigate({ to: data.route });
Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
}
(ref.current as HTMLElement).scrollIntoView({ inline: 'center' });
},
@ -104,12 +108,13 @@ function SettingsMenu (data: {})
const { ref, focusKey } = useFocusable({
focusable: true,
focusKey: 'settings-menu',
preferredChildFocusKey: location.hash.replace("#", '')
preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '')
});
return <ul
ref={ref}
className="flex flex-col portrait:flex-row md:text-2xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
style={{ viewTransitionName: 'settings-menu' }}
>
<FocusContext value={focusKey}>
<MenuItem
@ -147,25 +152,12 @@ function SettingsMenu (data: {})
route={"/"}
return
label="Return"
viewTransition={{ types: ['zoom-out'] }}
icon={<ArrowBigLeft />}
/>
</FocusContext>
</ul>;
}
function HandleGoBack ()
{
const { to, search } = PopSource('settings');
if (to)
{
console.log("Found source ", to, " to go back to");
}
Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search });
}
export function SettingsUI ()
{
const { ref, focusKey, focusSelf } = useFocusable({

View file

@ -1,174 +1,264 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import
{
useFocusable,
FocusContext,
setFocus,
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import { Router } from "@/mainview";
import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { PopSource } from "@/mainview/scripts/spatialNavigation";
import { systemApi } from "@/mainview/scripts/clientApi";
import queries from "@/mainview/scripts/queries";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, Download, Info, Settings } from "lucide-react";
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
import { ChevronDown, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert } from "lucide-react";
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
import { FrontEndEmulatorDetailed, RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots";
import { HeaderUI } from "@/mainview/components/Header";
import { useQuery } from "@tanstack/react-query";
import { StickyHeaderUI } from "@/mainview/components/Header";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils";
import toast from "react-hot-toast";
import { getErrorMessage } from "react-error-boundary";
import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard";
import StatList, { StatEntry } from "@/mainview/components/StatList";
import { GamesSection } from "@/mainview/components/store/GamesSection";
import { installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store";
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,
async loader (ctx)
{
const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id));
return { emulator };
ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery);
ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id));
}
});
function HomePageLink (data: { homepage: string; })
function HomePageLink (data: { homepage?: string; })
{
const { ref } = useFocusable({ focusKey: 'homepage-link' });
return <a ref={ref} className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1" onClick={() => systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}</a>;
return <a
ref={ref}
className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1"
onClick={() =>
{
if (data.homepage) systemApi.api.system.open.post({ url: data.homepage });
}}>
{data.homepage ?? <div className="skeleton h-4 w-54" />}
</a>;
}
function TitleArea (data: { emulator: FrontEndEmulator; })
function TitleArea (data: {
emulator?: FrontEndEmulatorDetailed;
onInstall: (source: string) => void;
})
{
const [installOpen, setInstallOpen] = useState(false);
const installOptions: DialogEntry[] = [];
const queryClient = useQueryClient();
const deleteMutation = useMutation({
...storeEmulatorDeleteMutation, onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(variables)),
});
const installProgressRef = useRef<HTMLProgressElement>(null);
const { data: installJob, status: installStatus } = useJobStatus('download-emulator', {
onError (error)
{
console.log(error);
toast.error(getErrorMessage(error) ?? "Error During Download");
},
onProgress (process)
{
if (installProgressRef.current)
installProgressRef.current.value = process;
},
onEnded (data)
{
console.log("Finished Install", data.emulator);
if (data.emulator)
queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator));
},
});
const isInstalling = !!installJob;
const options: DialogEntry[] = [];
if (data.emulator)
{
if (!isInstalling && !data.emulator?.validSource)
{
options.push(...data.emulator.downloads.map(d =>
{
const entry: DialogEntry = {
content: `Install From: ${d.name} (${d.type})`,
type: 'primary',
id: d.name,
action: (ctx) =>
{
data.onInstall(d.name);
ctx.close();
}
};
return entry;
}));
} else if (data.emulator.sources.find(s => s.type === 'store' && s.exists))
{
options.push({
content: "Delete",
type: 'error',
icon: <Trash2 />,
action (ctx)
{
if (data.emulator) deleteMutation.mutate(data.emulator.name);
ctx.close();
},
id: "delete"
});
}
}
const { ref, focusKey } = useFocusable({
focusKey: 'title-area',
preferredChildFocusKey: "install-btn",
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
});
return <div ref={ref} className="flex flex-wrap gap-4 items-center">
let installButtonContent = <></>;
if (!data.emulator)
{
installButtonContent = <span className="loading loading-spinner loading-lg"></span>;
}
else if (isInstalling)
{
installButtonContent = <><span className="loading loading-spinner loading-lg"></span>{installStatus}</>;
} else if (data.emulator.validSource)
{
installButtonContent = <><Settings /> Options</>;
} else if (data.emulator.downloads.length > 0)
{
installButtonContent = <><Download />Install</>;
} else
{
installButtonContent = <><TriangleAlert />Unsupported</>;
}
const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", {
content: <ContextList options={options} />
});
const handleOptionsOpen = () =>
{
if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false;
setOpen(true, 'install-btn');
};
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
<FocusContext value={focusKey}>
<img className="size-32" src={data.emulator.logo}></img>
<div className="flex flex-col grow justify-start gap-1">
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
<p className="flex gap-2">
{data.emulator.systems.map(({ id, name, icon }) =>
{data.emulator ? <img className="size-32" src={data.emulator.logo}></img> : <div className="skeleton h-32 w-32" />}
<div className="flex flex-col grow gap-1 sm:portrait:items-center md:items-start">
<h1 className="text-4xl font-semibold">{data.emulator?.name ?? <div className="skeleton h-10 w-84" />}</h1>
<div className="flex gap-2">
{data.emulator?.systems.map(({ id, name, icon }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
</div>;
})}
</p>
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
</div>
<div className="flex pt-2 gap-1">
<HomePageLink homepage={data.emulator.homepage} />
<HomePageLink homepage={data.emulator?.homepage} />
</div>
</div>
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
data.emulator.exists ?
<><Settings /> Options</> :
<><Download />Install</>
}
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
<ChevronDown />
</Button>
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
{
setInstallOpen(false);
setFocus("install-btn");
}}>
<ContextList options={installOptions}>
</ContextList>
</ContextDialog>
</FocusContext>
</div>;
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4">
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2" onAction={handleOptionsOpen} >
<div className="flex gap-4">
{installButtonContent}
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
<ChevronDown />
</div>
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
</Button>
</div>
{installOptionsDialog}
</FocusContext >
</div >;
}
function Description (data: { emulator: FrontEndEmulator; })
function Description (data: { emulator?: FrontEndEmulatorDetailed; })
{
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
<p>{data.emulator.description}</p>
<p>{data.emulator?.description ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[40%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-full"></div>
</div>}</p>
</div>;
}
export function RouteComponent ()
{
const { id } = Route.useParams();
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: `GAME_DETAIL_${id}`,
trackChildren: true,
preferredChildFocusKey: 'title-area'
});
const { emulator } = Route.useLoaderData();
const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id));
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id));
useShortcuts(focusKey, () => [{
label: "Return",
action: () =>
{
const { to, search } = PopSource('store-details');
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
},
action: HandleGoBack,
button: GamePadButtonCode.B
}]);
const installMutation = useMutation({
...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
});
useEffect(() =>
{
focusSelf();
}, []);
const { shortcuts } = useShortcutContext();
useStickyDataAttr(headerRef, sentinelRef, ref);
const stats: StatEntry[] = [];
if (emulator)
{
if (emulator.keywords)
stats.push({ label: "Tags", content: emulator.keywords });
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
stats.push(...emulator.sources.flatMap(s => [{ label: "Source", content: s.type, icon: emulatorStatusIcons[s.type] }, { label: "Location", content: s.binPath }]));
}
return (
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
<AnimatedBackground ref={ref} className="" scrolling>
<FocusContext.Provider value={focusKey}>
<div className="flex flex-col min-h-full z-10">
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
<HeaderUI />
<StickyHeaderUI ref={ref} />
<div className="flex flex-col z-10">
<div className="w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} onInstall={installMutation.mutate} />
<div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div>
<div className='mobile:hidden left-0 top-0 absolute bg-noise'></div>
</div>
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} />
</div>
<div className="flex flex-col bg-base-200 pt-4 min-h-0 grow text-lg">
<Screenshots screenshots={emulator.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />
<div className="flex flex-col bg-base-100 gap-4 pt-4 h-[50vh] min-h-128 grow text-lg">
{isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && <Screenshots className="grow bg-base-200" screenshots={emulator?.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />}
<Description emulator={emulator} />
</div>
<div className='mobile:hidden bg-gradient'></div>
<div className='mobile:hidden bg-noise'></div>
</div>
<div className="flex flex-col bg-base-100 py-4">
<div className="flex flex-col bg-base-100 py-4 gap-12 z-10">
<div className="divider"> <Info className="size-12" /> Stats</div>
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
{!!emulator.keywords &&
<li className="flex flex-wrap gap-2 items-center">
<div className="font-semibold">Tags:</div>
<div className="flex flex-wrap gap-2">{emulator.keywords?.map(k => <span className="rounded-full bg-base-200 px-3 py-1">{k}</span>)}</div>
</li>
}
{!!emulator.status.source &&
<li>
<div>Source</div>
<div>{emulator.status.source}</div>
</li>
}
{!!emulator.status.location &&
<li>
<div>Location</div>
<div>{emulator.status.location}</div>
</li>
}
</ul>
<div className="relative mt-16 bg-base-200">
{recommended && <EmulatorsSection
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
{recommendedEmulators && <div className="relative bg-base-200">
<EmulatorsSection
id={`${id}-recommended`}
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<h2 className="font-bold uppercase tracking-widest">
@ -177,11 +267,26 @@ export function RouteComponent ()
onFocus={scrollIntoViewHandler({ block: 'center' })}
onSelect={(id, focus) =>
{
setFocus("title-area");
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
Router.navigate({
to: '/store/details/emulator/$id', params: { id }
});
}}
emulators={recommended} />}
</div>
emulators={recommendedEmulators} />
</div>}
{recommendedGames && recommendedGames.length > 0 && <div className="px-6 py-3">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Related Games
</h2>
</div>
<GamesSection showSources={true} onFocus={scrollIntoViewHandler({ behavior: 'smooth', block: 'center' })} onSelect={(id) =>
{
Router.navigate({
to: '/game/$source/$id', params: { id: id.id, source: id.source }
});
}} games={recommendedGames} /></div>}
</div>
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
<Shortcuts shortcuts={shortcuts} />

View file

@ -8,7 +8,7 @@ import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard
import { StoreContext } from '@/mainview/scripts/contexts';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
import queries from '@/mainview/scripts/queries';
import { storeEmulatorsQuery } from '@queries/store';
export const Route = createFileRoute('/store/tab/emulators')({
component: RouteComponent,
@ -22,7 +22,7 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const storeContext = useContext(StoreContext);
const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery);
const { data: emulators } = useQuery(storeEmulatorsQuery);
useEffect(() =>
{

View file

@ -6,7 +6,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
import queries from '@/mainview/scripts/queries';
import { storeGamesInfiniteQuery } from '@queries/store';
export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent
@ -17,7 +17,7 @@ function RouteComponent ()
const { focus } = useSearch({ from: '/store/tab' });
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery);
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery);
useEffect(() =>
{
@ -52,7 +52,7 @@ function RouteComponent ()
<div className="skeleton h-4 w-[40%]"></div>
</div>)}
<LoadMoreButton
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
onFocus={handleFocus}
isFetching={isFetchingNextPage || isFetching}
onAction={() =>

View file

@ -5,15 +5,16 @@ import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
import { GamesSection } from "../../../components/store/GamesSection";
import { StatsSection } from "../../../components/store/StatsSection";
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
import queries from '@/mainview/scripts/queries';
import { useContext, useEffect, useRef, useState } from 'react';
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
import { StoreContext } from '@/mainview/scripts/contexts';
import { useInterval } from 'usehooks-ts';
import { Button } from '@/mainview/components/options/Button';
import { HardDrive, Search } from 'lucide-react';
import { Gamepad2, HardDrive, Search, Star } from 'lucide-react';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
import { autoEmulatorsQuery } from '@queries/settings';
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent
@ -106,9 +107,9 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
export function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) });
const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery);
const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
const { data: crucialEmulators, isSuccess } = useQuery({ ...autoEmulatorsQuery, select: (data) => data.filter(e => !e.validSource && e.isCritical) });
const { data: featuredGames } = useQuery(storeFeaturedGamesQuery);
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
const storeContext = useContext(StoreContext);
@ -137,11 +138,22 @@ export function RouteComponent ()
emulators={recommendedEmulators} />
</div>
<GamesSection
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
onFocus={scrollIntoViewHandler({ block: 'center' })}
games={featuredGames}
/>
<div className="px-6 py-3">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Featured Games
</h2>
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
</div>
<GamesSection
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
onFocus={scrollIntoViewHandler({ block: 'center' })}
games={featuredGames}
/>
</div>
<StatsSection
romCount={1240}

View file

@ -4,9 +4,9 @@ import { HeaderUI } from '@/mainview/components/Header';
import Shortcuts from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
import { SaveSource } from '@/mainview/scripts/spatialNavigation';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMatchRoute } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
@ -33,29 +33,51 @@ function TopArea (data: { filters: Record<string, FilterOption>; })
{
const { ref, focusKey } = useFocusable({
focusKey: 'top-area',
preferredChildFocusKey: 'store-tabs',
preferredChildFocusKey: `store-tabs`,
onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
}
});
useShortcuts("STORE_ROOT", () => [{
label: "Return",
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
button: GamePadButtonCode.B
}], []);
const handleNavigate = (s: string) =>
{
Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true });
};
return <div ref={ref}>
<FocusContext value={focusKey}>
<div className='w-full'>
<FilterUI containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters} setSelected={(s) => Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} />
<FilterUI rootFocusKey='STORE_ROOT' containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters}
setSelected={handleNavigate} />
</div>
</FocusContext>
</div>;
}
function StoreOutlet ()
{
const { ref, focusKey } = useFocusable({ focusKey: "STORE_OUTLET" });
return <div ref={ref}>
<FocusContext value={focusKey}>
<Outlet />
</FocusContext>
</div>;
}
function RouteComponent ()
{
// Root spatial nav container
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "STORE_ROOT",
trackChildren: true,
preferredChildFocusKey: 'top-area'
preferredChildFocusKey: 'top-area',
forceFocus: true
});
const headerRef = useRef(null);
const sentinelRef = useRef(null);
@ -65,34 +87,6 @@ function RouteComponent ()
games: { label: "Games", selected: useIsSettings('games') }
};
useShortcuts(focusKey, () => [{
label: "Return",
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
button: GamePadButtonCode.B
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.R1
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.L1
}], [filters]);
const { shortcuts } = useShortcutContext();
const { focus } = Route.useSearch();
@ -102,31 +96,24 @@ function RouteComponent ()
{
focusSelf();
}
}, []);
const handleDetails = (type: string, source: string, id: string, focus: string) =>
{
if (type === 'emulator')
{
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
}
else if (type === 'game')
{
console.log(source, id);
SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } });
}
};
const match = Route.useMatch();
const goToSettings = () =>
{
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
Router.navigate({ to: '/settings' });
};
const isMobile = mobileCheck();
@ -141,7 +128,7 @@ function RouteComponent ()
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
</div>
<TopArea filters={filters} />
<Outlet />
<StoreOutlet />
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
<Shortcuts shortcuts={shortcuts} />
</div>

View file

@ -2,3 +2,5 @@ export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="
<title>Twitch</title>
<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 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

@ -32,3 +32,7 @@ export const FilePickerContext = createContext<{
drives: Drive[],
activeDrive: Drive | undefined;
}>({} as any);
export const GameDetailsContext = createContext<{
update: () => void;
}>({} as any);

View file

@ -1,11 +0,0 @@
import system from "./queries/system";
import settings from "./queries/settings";
import romm from "./queries/romm";
import store from "./queries/store";
export default {
system,
settings,
romm,
store
};

View file

@ -4,76 +4,116 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query";
import z from "zod";
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
export default {
allGamesQuery: (filter?: GameListFilterType) => queryOptions({
queryKey: ['games', filter ?? 'all'],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.games.get({ query: filter });
if (error) throw error;
return data;
}
}),
gameQuery: (source: string, id: string) => queryOptions({
queryKey: ['game', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
if (error) throw error;
return data;
},
}),
rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }),
rommQrLoginMutation: mutationOptions({
mutationKey: ['login', 'qr', 'cancel'],
mutationFn: () => rommApi.api.romm.login.romm.post()
}),
rommLoginMutation: mutationOptions({
mutationKey: ["romm", "login"],
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
{
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
if (error) throw error;
},
onSuccess: (d, v, r, c) =>
{
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
},
onError: (e) =>
{
console.error(e);
},
}),
rommUserQuery: () => queryOptions({
...getCurrentUserApiUsersMeGetOptions(),
queryKey: ['romm', 'auth', "login"],
refetchOnWindowFocus: false,
retry: 0
}),
rommGetOptionsQuery: () => queryOptions({
...statsApiStatsGetOptions(),
refetchInterval: 30000,
retry: false,
}),
rommHasPasswordQuery: queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }),
rommHostnameQuery: queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }),
rommUsernameQuery: queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }),
deleteGameMutation: (id: FrontEndId) => mutationOptions({
mutationKey: ['delete', id],
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
}),
getCollectionsQuery: () => queryOptions({
...getCollectionsApiCollectionsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime
}),
getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }),
platformQuery: (source: string, id: string) => queryOptions({
queryKey: ['platform', source, id], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
if (error) throw error;
return data;
}, staleTime: DefaultRommStaleTime
})
};
export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({
queryKey: ['games', filter ?? 'all'],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.games.get({ query: filter });
if (error) throw error;
return data;
}
});
export const gameQuery = (source: string, id: string) => queryOptions({
queryKey: ['game', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
if (error) throw error;
return data;
},
});
export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() });
export const rommQrLoginMutation = mutationOptions({
mutationKey: ['login', 'qr', 'cancel'],
mutationFn: () => rommApi.api.romm.login.romm.post()
});
export const rommLoginMutation = mutationOptions({
mutationKey: ["romm", "login"],
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
{
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
if (error) throw error;
},
onSuccess: (d, v, r, c) =>
{
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
},
onError: (e) =>
{
console.error(e);
},
});
export const rommUserQuery = () => queryOptions({
...getCurrentUserApiUsersMeGetOptions(),
queryKey: ['romm', 'auth', "login"],
refetchOnWindowFocus: false,
retry: 0
});
export const rommGetOptionsQuery = () => queryOptions({
...statsApiStatsGetOptions(),
refetchInterval: 30000,
retry: false,
});
export const rommHasPasswordQuery = queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
export const deleteGameMutation = (id: FrontEndId) => mutationOptions({
mutationKey: ['delete', id],
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
});
export const getCollectionsQuery = () => queryOptions({
...getCollectionsApiCollectionsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime
});
export const getCollectionQuery = (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) });
export const platformQuery = (source: string, id: string) => queryOptions({
queryKey: ['platform', source, id], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
if (error) throw error;
return data;
}, staleTime: DefaultRommStaleTime
});
export const installMutation = (source: string, id: string) => mutationOptions({
mutationKey: ['install', source, id],
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source })({ id }).install.post();
if (error) throw error;
}
});
export const cancelInstallMutation = (source: string, id: string) => mutationOptions({
mutationKey: ['install', 'cancel', source, id],
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source })({ id }).install.delete();
if (error) throw error;
}
});
export const playMutation = mutationOptions({
mutationKey: ['play'],
mutationFn: async (data: { source: string, id: string; command_id?: string | number; }) =>
{
const { error } = await rommApi.api.romm.game({ source: data.source })({ id: data.id }).play.post({ command_id: data.command_id });
if (error)
throw error;
}
});
export const gamesRecommendedBasedOnEmulatorQuery = (id: string) => queryOptions({
queryKey: ['games', 'recommended', 'emulator', id], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.recommended.games.emulator({ id }).get();
if (error) throw error;
return data;
}
});
export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) => queryOptions({
queryKey: ['games', 'recommended', 'game', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.recommended.games.game({ source })({ id }).get();
if (error) throw error;
return data;
}
});

View file

@ -3,132 +3,130 @@ import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast";
import { rommApi, settingsApi } from "../clientApi";
export default {
changeDownloadsMutation: mutationOptions({
mutationKey: ["setting", "downloads"],
mutationFn: async (value: any) =>
export const changeDownloadsMutation = mutationOptions({
mutationKey: ["setting", "downloads"],
mutationFn: async (value: any) =>
{
const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d =>
{
const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d =>
{
if (d.error) throw d.error;
return d.data;
}), {
success: e => `Download Moved to ${e}`,
loading: "Moving Download",
error: e => getErrorMessage(e) ?? "Error Moving Download"
});
if (d.error) throw d.error;
return d.data;
}), {
success: e => `Download Moved to ${e}`,
loading: "Moving Download",
error: e => getErrorMessage(e) ?? "Error Moving Download"
});
return response;
return response;
}
});
export const autoEmulatorsQuery = queryOptions({
queryKey: ['auto-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
if (error) throw error;
return data;
}
});
export const twitchLogoutMutation = mutationOptions({
mutationKey: ['twitch', 'logout'],
mutationFn: () =>
{
return rommApi.api.romm.logout.twitch.post();
}
});
export const twitchLoginMutation = mutationOptions({
mutationKey: ['twitch', 'login'],
mutationFn: (openInBrowser: boolean) =>
{
return rommApi.api.romm.login.twitch.post({ openInBrowser });
}
});
export const twitchLoginVerificationQuery = queryOptions({
queryKey: ['twitch', 'login', 'status'],
retry (failureCount, error)
{
if ((error as any).status === 404)
{
return false;
}
}),
autoEmulatorsQuery: queryOptions({
queryKey: ['auto-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
if (error) throw error;
return data;
}
}),
twitchLogoutMutation: mutationOptions({
mutationKey: ['twitch', 'logout'],
mutationFn: () =>
{
return rommApi.api.romm.logout.twitch.post();
}
}),
twitchLoginMutation: mutationOptions({
mutationKey: ['twitch', 'login'],
mutationFn: (openInBrowser: boolean) =>
{
return rommApi.api.romm.login.twitch.post({ openInBrowser });
}
}),
twitchLoginVerificationQuery: queryOptions({
queryKey: ['twitch', 'login', 'status'],
retry (failureCount, error)
{
if ((error as any).status === 404)
{
return false;
}
return failureCount < 3;
},
queryFn: async () =>
{
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
if (error) throw { ...error, status };
return data;
}
}),
customEmulatorsQuery: queryOptions({
queryKey: ['custom-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
if (error) throw error;
return data;
}
}),
customEmulatorAddMutation: mutationOptions({
mutationKey: ['emulator', 'custom', 'add'],
mutationFn: async (id: string) =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
if (error) throw error;
return data;
},
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
}),
customEmulatorDeleteMutation: (id: string) => mutationOptions({
mutationKey: ["emulator", id, 'delete'],
mutationFn: async () =>
{
const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete();
if (error) throw error;
},
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
}),
setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({
mutationKey: ["emulator", id, 'set'],
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ["emulator", id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
onSuccess?.(v);
}
}),
customEmulatorRemoveValueQuery: (id?: string) => queryOptions({
enabled: !!id,
queryKey: ["emulator", id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get();
if (error) throw error;
return value;
},
}),
setSettingMutation: (id?: string) => mutationOptions({
mutationKey: ["setting", id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: id! }).post({ value });
if (response.error) throw response.error;
return response.data;
}
}),
getSettingQuery: (id: string | undefined) => queryOptions({
enabled: !!id,
queryKey: ["setting", id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: id! }).get();
if (error) throw error;
return failureCount < 3;
},
queryFn: async () =>
{
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
if (error) throw { ...error, status };
return data;
}
});
export const customEmulatorsQuery = queryOptions({
queryKey: ['custom-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
if (error) throw error;
return data;
}
});
export const customEmulatorAddMutation = mutationOptions({
mutationKey: ['emulator', 'custom', 'add'],
mutationFn: async (id: string) =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
if (error) throw error;
return data;
},
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
});
export const customEmulatorDeleteMutation = (id: string) => mutationOptions({
mutationKey: ["emulator", id, 'delete'],
mutationFn: async () =>
{
const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete();
if (error) throw error;
},
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
export const setCustomEmulatorMutation = (id: string, onSuccess?: (value: string) => void) => mutationOptions({
mutationKey: ["emulator", id, 'set'],
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ["emulator", id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
onSuccess?.(v);
}
});
export const customEmulatorRemoveValueQuery = (id?: string) => queryOptions({
enabled: !!id,
queryKey: ["emulator", id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get();
if (error) throw error;
return value;
},
});
export const setSettingMutation = (id?: string) => mutationOptions({
mutationKey: ["setting", id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: id! }).post({ value });
if (response.error) throw response.error;
return response.data;
}
});
export const getSettingQuery = (id: string | undefined) => queryOptions({
enabled: !!id,
queryKey: ["setting", id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: id! }).get();
if (error) throw error;
return value.value;
},
})
};
return value.value;
},
});

View file

@ -1,58 +1,74 @@
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
import { rommApi, storeApi } from "../clientApi";
import { FrontEndGameType } from "@/shared/constants";
export default {
storeEmulatorsQuery: queryOptions({
queryKey: ['store-emulators'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get();
if (error) throw error;
return data;
}
}),
storeFeaturedGamesQuery: queryOptions({
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.games.featured.get();
if (error) throw error;
return data;
}
}),
storeEmulatorsRecommendedQuery: queryOptions({
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
if (error) throw error;
return data;
}
}),
storeEmulatorDetailsQuery: (id: string) => queryOptions({
queryKey: ['store-emulator', id], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
if (error) throw error;
return data;
}
}),
storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
initialPageParam: 0,
queryKey: ['store-games'],
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
queryFn: async (data) =>
{
const pageParam = data.pageParam as number;
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
if (error) throw error;
return { data: games.games, nextPage: pageParam + 1 };
}
}),
storeGetStatsQuery: queryOptions({
queryKey: ['store', 'stats'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.stats.get();
if (error) throw error;
return data;
}
})
};
export const storeEmulatorsQuery = queryOptions({
queryKey: ['store-emulators'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get();
if (error) throw error;
return data;
}
});
export const storeFeaturedGamesQuery = queryOptions({
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.games.featured.get();
if (error) throw error;
return data;
}
});
export const storeEmulatorsRecommendedQuery = queryOptions({
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
if (error) throw error;
return data;
}
});
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
queryKey: ['store-emulator', id], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulator({ id }).get();
if (error) throw error;
return data;
}
});
export const storeEmulatorDeleteMutation = mutationOptions({
mutationKey: ['store-emulator', 'delete'],
mutationFn: async (id: string) =>
{
const { error } = await storeApi.api.store.emulator({ id }).delete();
if (error) throw error;
}
});
export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
initialPageParam: 0,
queryKey: ['store-games'],
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
queryFn: async (data) =>
{
const pageParam = data.pageParam as number;
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
if (error) throw error;
return { data: games.games, nextPage: pageParam + 1 };
}
});
export const storeGetStatsQuery = queryOptions({
queryKey: ['store', 'stats'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.stats.get();
if (error) throw error;
return data;
}
});
export const installEmulatorMutation = (id: string) => mutationOptions({
mutationKey: ['install', 'emulator', id],
mutationFn: async (source: string) =>
{
const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post();
if (error) throw error;
return data;
}
});

View file

@ -1,51 +1,49 @@
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
import { systemApi } from "../clientApi";
export default {
drivesQuery: queryOptions({
queryKey: ['drives'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.get();
if (error) throw error;
return data;
}
}),
downloadDrivesQuery: queryOptions({
queryKey: ['drives', 'download'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.download.get();
if (error) throw error;
return data;
}
}),
filesQuery: (currentPath: string | undefined, id: string) => queryOptions({
queryKey: ['files', currentPath ?? '', id],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
if (error) throw error;
return data;
},
placeholderData: keepPreviousData
}),
systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }),
createFolderMutation: (id: string) => mutationOptions({
export const drivesQuery = queryOptions({
queryKey: ['drives'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.get();
if (error) throw error;
return data;
}
});
export const downloadDrivesQuery = queryOptions({
queryKey: ['drives', 'download'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.download.get();
if (error) throw error;
return data;
}
});
export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({
queryKey: ['files', currentPath ?? '', id],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
if (error) throw error;
return data;
},
placeholderData: keepPreviousData
});
export const systemInfoQuery = queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
export const createFolderMutation = (id: string) => mutationOptions({
mutationKey: ['create', 'folder', id],
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
{
if (!name) return;
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
if (error) throw error.value;
},
}),
closeMutation: mutationOptions({
mutationKey: ['close'], mutationFn: async () =>
{
const { error } = await systemApi.api.system.exit.post();
if (error) throw error;
}
})
};
mutationKey: ['create', 'folder', id],
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
{
if (!name) return;
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
if (error) throw error.value;
},
});
export const closeMutation = mutationOptions({
mutationKey: ['close'], mutationFn: async () =>
{
const { error } = await systemApi.api.system.exit.post();
if (error) throw error;
}
});

View file

@ -9,8 +9,6 @@ import
UseFocusableResult,
} from "@noriginmedia/norigin-spatial-navigation";
import { RefObject, useEffect, useState } from "react";
import { Router } from "..";
import { RouteIds } from "@tanstack/react-router";
init({
shouldFocusDOMNode: false,
@ -22,43 +20,10 @@ let updateFocusable = SpatialNavigation.updateFocusable.bind(SpatialNavigation);
let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation);
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
type SaveFocusType = "session" | "local";
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list' | 'store-details';
const historySourceMap = new Map<string, { to: string, search?: Record<string, any>; }>();
export function SaveSource (id: HistorySourceType, init?: { url?: string, search?: Record<string, any>; })
{
let finalUrl = init?.url ?? location.hash.replaceAll(/#|(\?.+)/g, '');
if (finalUrl)
{
historySourceMap.set(id, { to: finalUrl, search: init?.search });
}
}
export function HasSource (id: HistorySourceType)
{
return historySourceMap.has(id);
}
export function PopSource (id: HistorySourceType)
{
if (!historySourceMap.has(id))
{
return { to: undefined, search: undefined };
}
const source = historySourceMap.get(id);
historySourceMap.delete(id);
return source ?? { to: undefined, search: undefined };
}
export function PopNavigateSource (id: HistorySourceType, fallback: RouteIds<typeof Router.routeTree>)
{
const { to, search } = PopSource(id);
Router.navigate({ to: to ?? fallback, viewTransition: { types: ['zoom-out'] }, search });
}
export function GetFocusedElement (focusKey: string)
{
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement | undefined;
@ -128,6 +93,11 @@ SpatialNavigation.setFocus = (newFocusKey, focusDetails) =>
dispatchFocusedEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
};
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
{
setCurrentFocusedKey(newFocusKey, focusDetails);
window.dispatchEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
};
SpatialNavigation.updateFocusable = (key, data) =>
{

View file

@ -1,3 +1,5 @@
import { FrontEndId } from "@/shared/constants";
export const FOCUS_KEYS = {
NAV_CATEGORIES: "NAV_CATEGORIES",
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
@ -6,6 +8,6 @@ export const FOCUS_KEYS = {
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: string) => `GAME_${id}`,
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
STATS_SECTION: "STATS_SECTION",
} as const;

View file

@ -1,9 +1,11 @@
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { doesFocusableExist, FocusableComponentLayout, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { RefObject, useEffect, useRef, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc";
import { Router } from "..";
import data from "@emulators";
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
{
@ -224,10 +226,14 @@ export function useDragScroll<T extends HTMLElement | null> (ref: RefObject<T>)
export function scrollIntoViewHandler (params?: ScrollIntoViewOptions)
{
return (focusKey: string, node: HTMLElement, details: any) => node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' });
return (focusKey: string, node: HTMLElement, details: any) =>
{
if (details.nativeEvent instanceof PointerEvent) return;
node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' });
};
}
export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement, T3 extends HTMLElement> (ref: RefObject<T | null>, sentinelRef: RefObject<T2 | null>, scrollRef: RefObject<T3 | null>)
export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement, T3 extends HTMLElement> (ref: RefObject<T | null>, sentinelRef: RefObject<T2 | null>, scrollRef: RefObject<T3 | null>, callback?: (stuck: boolean) => void)
{
useEffect(() =>
{
@ -239,6 +245,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
([entry]) =>
{
el.toggleAttribute("data-stuck", !entry.isIntersecting);
callback?.(!entry.isIntersecting);
},
{
root: scrollRef.current ?? null,
@ -249,7 +256,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollRef.current]);
}, [scrollRef.current, callback]);
}
type ExtractField<T, TYPE, K extends string> =
@ -261,18 +268,19 @@ type JobResponse<JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> =
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
id: JOB,
init?: {
onProgress?: (process: number) => void,
onEnded?: () => void;
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress", 'data'>) => void,
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void;
}
)
{
type Response = JobResponse<JOB>;
type DataPayload = ExtractField<Response, 'data' | 'progress' | 'started', 'data'>;
type DataPayload = ExtractField<Response, 'data' | 'progress' | 'started' | 'ended' | 'completed', 'data'>;
const ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
const [data, setData] = useState<DataPayload>();
const [status, setStatus] = useState<string>();
const [error, setError] = useState<unknown>();
const [error, setError] = useState<string>();
useEffect(() =>
{
@ -287,9 +295,13 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
setError(data.error);
setStatus(status);
setData(undefined);
init?.onError?.(data.error);
break;
case 'ended':
init?.onEnded?.();
setStatus(status);
setData(undefined);
init?.onEnded?.(data.data as any);
break;
case 'completed':
setStatus(status);
setData(undefined);
@ -297,7 +309,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
default:
setData(data.data as DataPayload);
setStatus(status);
init?.onProgress?.(data.progress);
init?.onProgress?.(data.progress, data.data);
}
});
@ -311,3 +323,13 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
return { data, status, error, wsRef: ref };
}
export function HandleGoBack ()
{
if (Router.history.canGoBack())
{
Router.history.back();
} else
{
Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } });
}
}

View file

@ -32,4 +32,5 @@ interface FilterOption extends FocusParams, InteractParams
{
label: string;
selected: boolean;
icon?: any;
}

View file

@ -1,4 +1,5 @@
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
import { JSX } from 'react';
import * as z from 'zod';
@ -128,27 +129,73 @@ export const EmulatorPackageSchema = z.object({
type: z.enum(['emulator']),
os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
keywords: z.array(z.string()).optional(),
downloads: z.record(z.string(), z.object({ type: z.string(), url: z.url() })).optional(),
downloads: z.record(z.string(), z.array(z.object({
type: z.string(),
url: z.url().optional(),
pattern: z.string(),
path: z.string().optional()
}))).optional(),
systems: z.array(z.string())
});
export const GithubReleaseSchema = z.object({
assets: z.array(z.object({
name: z.string(),
browser_download_url: z.url(),
content_type: z.string().optional()
}))
});
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
export type StoreGameType = z.infer<typeof StoreGameSchema>;
export interface FrontEndEmulator extends Omit<EmulatorPackageType, 'systems'>
export interface EmulatorSourceType
{
binPath: string;
rootPath?: string;
type: string;
exists: boolean;
}
export interface FrontEndEmulator
{
name: string;
logo: string;
systems: { id: string, name: string, icon: string; }[];
gameCount: number;
exists: boolean;
validSource?: EmulatorSourceType;
}
export interface FrontEndEmulatorDetailedDownload
{
name: string;
type: string | undefined;
}
export interface FrontEndEmulatorDetailed extends FrontEndEmulator
{
homepage: string;
description: string;
downloads: FrontEndEmulatorDetailedDownload[];
keywords?: string[];
screenshots: string[];
status: {
source?: string;
location?: string;
};
sources: EmulatorSourceType[];
}
export interface FrontEndGameTypeDetailedAchievement
{
id: string;
title: string;
description?: string;
date?: Date;
date_hardcode?: Date;
badge_url?: string;
display_order: number;
type?: string;
}
export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
{
}
export interface FrontEndGameTypeDetailed extends FrontEndGameType
@ -157,9 +204,14 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType
fs_size_bytes: number | null;
missing: boolean;
local: boolean;
genres?: string[];
companies?: string[];
release_date?: Date;
emulators?: FrontEndGameTypeDetailedEmulator[],
achievements?: {
unlocked: number;
total: number;
entires: FrontEndGameTypeDetailedAchievement[];
};
};
@ -195,6 +247,7 @@ export interface Notification
title?: string;
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
}
export interface CommandEntry
@ -202,10 +255,15 @@ export interface CommandEntry
id: string | number;
label?: string;
command: string;
startDir?: string;
valid: boolean;
emulator?: string;
}
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
export type SettingsType = z.infer<typeof SettingsSchema>;
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
export interface GameInstallProgress
@ -229,4 +287,4 @@ export const GameflowPluginSchema = z.object({
launchGame: z.function({ input: [GameLaunchSchema] })
});
export interface GameflowPlugin extends z.infer<typeof GameflowPluginSchema> { }
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing';
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';

View file

@ -34,6 +34,9 @@
"@schema/*": [
"./src/bun/api/schema/*"
],
"@queries/*": [
"./src/mainview/scripts/queries/*"
]
}
},
"include": [

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -36,6 +36,13 @@
<entry>come.nanodata.armsx2.debug/kr.co.iefriends.pcsx2.MainActivity</entry>
</rule>
</emulator>
<emulator name="AX360E">
<!-- Microsoft Xbox 360 emulator aX360e -->
<rule type="androidpackage">
<entry>aenu.ax360e/aenu.ax360e.EmulatorActivity</entry>
<entry>aenu.ax360e.free/aenu.ax360e.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="AZAHAR">
<!-- Nintendo 3DS emulator Azahar -->
<rule type="androidpackage">
@ -372,6 +379,12 @@
<entry>com.PceEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="PICO-8">
<!-- PICO-8 Fantasy Console (game engine) -->
<rule type="androidpackage">
<entry>io.wip.pico8/com.godot.game.GodotAppLauncher</entry>
</rule>
</emulator>
<emulator name="PIZZA-BOY-GBA">
<!-- Nintendo Game Boy Advance emulator Pizza Boy GBA -->
<rule type="androidpackage">
@ -462,6 +475,12 @@
<entry>com.fms.speccy/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="SUPER3">
<!-- Sega Model 3 emulator SUPER3 -->
<rule type="androidpackage">
<entry>com.izzy2lost.super3/com.izzy2lost.super3.MainActivity</entry>
</rule>
</emulator>
<emulator name="SWAN-EMU">
<!-- Bandai WonderSwan emulator Swan.emu -->
<rule type="androidpackage">
@ -513,6 +532,12 @@
<entry>com.cmodded.winlator/com.winlator.XServerDisplayActivity</entry>
</rule>
</emulator>
<emulator name="X1-BOX">
<!-- Microsoft Xbox emulator X1 BOX -->
<rule type="androidpackage">
<entry>com.izzy2lost.x1box/.LauncherActivity</entry>
</rule>
</emulator>
<emulator name="YABASANSHIRO-2">
<!-- Sega Saturn emulator Yaba Sanshiro 2 -->
<rule type="androidpackage">

View file

@ -387,6 +387,7 @@
<command label="Flycast">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/flycast_libretro_android.so %EXTRA_ROM%=%ROM%</command>
<command label="Flycast (Standalone)">%EMULATOR_FLYCAST% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
<command label="Play! (Standalone)">%EMULATOR_PLAY!% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF%</command>
<platform>arcade</platform>
<theme>consolearcade</theme>
</system>
@ -1060,6 +1061,7 @@
<fullname>Sega Model 3</fullname>
<path>%ROMPATH%/model3</path>
<extension>.7z .7Z .zip .ZIP</extension>
<command label="SUPER3 (Standalone)">%EMULATOR_SUPER3% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
<command label="MAME - Current">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/mamearcade_libretro_android.so %EXTRA_ROM%=%ROM%</command>
<command label="MAME4droid Current (Standalone)">%EMULATOR_MAME4DROID-CURRENT% %ACTION%=android.intent.action.VIEW %EXTRA_cli_params%="-rompath '%GAMEDIRRAW%;%ROMPATHRAW%/model3'" %DATA%=%ROMPROVIDER%</command>
<platform>arcade</platform>
@ -1426,6 +1428,7 @@
<fullname>PICO-8 Fantasy Console</fullname>
<path>%ROMPATH%/pico8</path>
<extension>.p8 .P8 .png .PNG</extension>
<command label="PICO-8 (Standalone)">%EMULATOR_PICO-8% %DATA%=%ROMSAF%</command>
<command label="Fake-08">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/fake08_libretro_android.so %EXTRA_ROM%=%ROM%</command>
<command label="Retro8">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/retro8_libretro_android.so %EXTRA_ROM%=%ROM%</command>
<command label="Infinity (Standalone)">%EMULATOR_INFINITY% %ACTION%=android.intent.action.VIEW %DATA%=%ROMPROVIDER%</command>
@ -1764,7 +1767,7 @@
<name>steam</name>
<fullname>Valve Steam</fullname>
<path>%ROMPATH%/steam</path>
<extension>.steam</extension>
<extension>.pcgame .steam</extension>
<command label="GameNative (Standalone)">%EMULATOR_GAMENATIVE% %ACTION%=app.gamenative.LAUNCH_GAME %EXTRAINTEGER_app_id%=%INJECT%=%ROM%</command>
<command label="GameHub Lite (Standalone)">%EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_steamAppId%=%INJECT%=%ROM% %EXTRA_localGameId%=%INJECT%=%ROM%</command>
<command label="GameHub Lite Local (Standalone)">%EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_localGameId%=%INJECT%=%ROM%</command>
@ -1914,8 +1917,8 @@
<name>triforce</name>
<fullname>Namco-Sega-Nintendo Triforce</fullname>
<path>%ROMPATH%/triforce</path>
<extension>.7z .7Z .zip .ZIP</extension>
<command>PLACEHOLDER %ROM%</command>
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF%</command>
<platform>arcade</platform>
<theme>triforce</theme>
</system>
@ -2048,7 +2051,7 @@
<name>windows</name>
<fullname>Microsoft Windows</fullname>
<path>%ROMPATH%/windows</path>
<extension>.desktop .steam</extension>
<extension>.desktop .epic .gog .pcgame .steam</extension>
<command label="Winlator Cmod (Standalone)">%EMULATOR_WINLATOR-CMOD% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
<command label="Winlator Cmod Glibc (Standalone)">%EMULATOR_WINLATOR-GLIBC% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
<command label="Winlator Cmod PRoot (Standalone)">%EMULATOR_WINLATOR-PROOT% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
@ -2121,8 +2124,8 @@
<name>xbox</name>
<fullname>Microsoft Xbox</fullname>
<path>%ROMPATH%/xbox</path>
<extension>.7z .7Z .zip .ZIP</extension>
<command>PLACEHOLDER %ROM%</command>
<extension>.iso .ISO .xiso .XISO</extension>
<command label="X1 BOX (Standalone)">%EMULATOR_X1-BOX% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
<platform>xbox</platform>
<theme>xbox</theme>
</system>
@ -2130,8 +2133,8 @@
<name>xbox360</name>
<fullname>Microsoft Xbox 360</fullname>
<path>%ROMPATH%/xbox360</path>
<extension>.7z .7Z .zip .ZIP</extension>
<command>PLACEHOLDER %ROM%</command>
<extension>. .iso .ISO .xex .XEX .zar .ZAR</extension>
<command label="aX360e (Standalone)">%EMULATOR_AX360E% %ACTION%=aenu.intent.action.AX360E %EXTRA_game_uri%=%ROMSAF%</command>
<platform>xbox360</platform>
<theme>xbox360</theme>
</system>

View file

@ -439,6 +439,7 @@
<command label="Play! Disc (Standalone)">%EMULATOR_PLAY!% --fullscreen --disc %ROM%</command>
<command label="RPCS3 Shortcut (Standalone)">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
<command label="RPCS3 Game Serial (Standalone)">%EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3</command>
<command label="Dolphin (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM%</command>
<command label="Triforce (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM%</command>
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
@ -2141,6 +2142,7 @@
<fullname>Namco-Sega-Nintendo Triforce</fullname>
<path>%ROMPATH%/triforce</path>
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
<command label="Dolphin (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM%</command>
<command label="Triforce (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM%</command>
<platform>arcade</platform>
<theme>triforce</theme>
@ -2373,7 +2375,7 @@
<name>xbox</name>
<fullname>Microsoft Xbox</fullname>
<path>%ROMPATH%/xbox</path>
<extension>.iso .ISO</extension>
<extension>.iso .ISO .xiso .XISO</extension>
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
<platform>xbox</platform>
<theme>xbox</theme>

View file

@ -2244,7 +2244,7 @@
<name>xbox</name>
<fullname>Microsoft Xbox</fullname>
<path>%ROMPATH%/xbox</path>
<extension>.iso .ISO</extension>
<extension>.iso .ISO .xiso .XISO</extension>
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
<platform>xbox</platform>
<theme>xbox</theme>

View file

@ -421,6 +421,7 @@
<command label="Play! Disc (Standalone)">%EMULATOR_PLAY!% --fullscreen --disc %ROM%</command>
<command label="RPCS3 Shortcut (Standalone)">%RUNINBACKGROUND% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
<command label="RPCS3 Game Serial (Standalone)">%EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3</command>
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% -b -e %ROM%</command>
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
<platform>arcade</platform>
@ -2005,7 +2006,7 @@
<fullname>Namco-Sega-Nintendo Triforce</fullname>
<path>%ROMPATH%/triforce</path>
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
<command>PLACEHOLDER %ROM%</command>
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% -b -e %ROM%</command>
<platform>arcade</platform>
<theme>triforce</theme>
</system>
@ -2218,7 +2219,7 @@
<name>xbox</name>
<fullname>Microsoft Xbox</fullname>
<path>%ROMPATH%/xbox</path>
<extension>.iso .ISO</extension>
<extension>.iso .ISO .xiso .XISO</extension>
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
<platform>xbox</platform>
<theme>xbox</theme>

Some files were not shown because too many files have changed in this diff Show more