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:
parent
cf6fff6fac
commit
3750e9ed8f
103 changed files with 4888 additions and 1632 deletions
5
bun.lock
5
bun.lock
|
|
@ -5,6 +5,7 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "electrobun-hello-world",
|
"name": "electrobun-hello-world",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"7zip-min": "^3.0.1",
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.6",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
|
|
@ -83,6 +84,10 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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": ["@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=="],
|
"@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="],
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
"packageManager": "bun@1.3.9",
|
"packageManager": "bun@1.3.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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'",
|
"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:prod:vite": "NODE_ENV=production bun run build:vite",
|
||||||
"build:dev:vite": "NODE_ENV=development 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",
|
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
||||||
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
"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",
|
"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",
|
"drizzle:generate": "bunx drizzle-kit generate",
|
||||||
"test": "bun test",
|
"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",
|
"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"
|
"package:Windows": "bun run build:prod"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"7zip-min": "^3.0.1",
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.6",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// watcher.ts - run this instead of --watch
|
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import browser from '../src/bun/browser';
|
import browser from '../src/bun/browser';
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
|
@ -13,9 +12,9 @@ let retries = 0;
|
||||||
|
|
||||||
function spawnServer ()
|
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: {
|
env: {
|
||||||
...Bun.env,
|
...process.env,
|
||||||
HEADLESS: "true",
|
HEADLESS: "true",
|
||||||
},
|
},
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
|
|
@ -50,7 +49,7 @@ function spawnBrowser ()
|
||||||
try
|
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)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { Database } from "bun:sqlite";
|
||||||
import * as schema from '../src/bun/api/schema/emulators';
|
import * as schema from '../src/bun/api/schema/emulators';
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import path from 'node:path';
|
|
||||||
import { ensureDir } from 'fs-extra';
|
|
||||||
|
|
||||||
/** get all latest supported romm platforms */
|
/** get all latest supported romm platforms */
|
||||||
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
|
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 emulators = $r('ruleList emulator').toArray().map(s =>
|
||||||
{
|
{
|
||||||
const $emulator = $r(s);
|
const $emulator = $r(s);
|
||||||
|
const comment = $emulator.contents().toArray().find((node) => node.type === 'comment');
|
||||||
const $systempath = $emulator.find('rule[type=systempath] entry');
|
const $systempath = $emulator.find('rule[type=systempath] entry');
|
||||||
const $staticpath = $emulator.find('rule[type=staticpath] entry');
|
const $staticpath = $emulator.find('rule[type=staticpath] entry');
|
||||||
const $corepath = $emulator.find('rule[type=corepath] 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 emulatorName = $emulator.attr('name');
|
||||||
const emulator: typeof schema.emulators.$inferInsert = {
|
const emulator: typeof schema.emulators.$inferInsert = {
|
||||||
name: emulatorName!,
|
name: emulatorName!,
|
||||||
|
fullname: comment?.data.trim(),
|
||||||
systempath: $systempath.toArray().map(p => $r(p).text()),
|
systempath: $systempath.toArray().map(p => $r(p).text()),
|
||||||
staticpath: $staticpath.toArray().map(p => $r(p).text()),
|
staticpath: $staticpath.toArray().map(p => $r(p).text()),
|
||||||
corepath: $corepath.toArray().map(p => $r(p).text()),
|
corepath: $corepath.toArray().map(p => $r(p).text()),
|
||||||
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
|
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
|
||||||
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
||||||
};
|
};
|
||||||
|
|
||||||
return emulator;
|
return emulator;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -143,6 +144,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
commands,
|
commands,
|
||||||
mappings
|
mappings
|
||||||
};
|
};
|
||||||
|
|
||||||
return system;
|
return system;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ import { appPath, getErrorMessage } from "../utils";
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import UpdateStoreJob from "./jobs/update-store";
|
import UpdateStoreJob from "./jobs/update-store";
|
||||||
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
|
|
||||||
export const config = new Conf<SettingsType>({
|
export const config = new Conf<SettingsType>({
|
||||||
projectName: projectPackage.name,
|
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("Config Path Located At: ", config.path);
|
||||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||||
console.log("App Directory is ", process.env.APPDIR);
|
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'));
|
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||||
export const jar = new CookieJar(fileCookieStore);
|
export const jar = new CookieJar(fileCookieStore);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { cache } from "./app";
|
import { cache } from "./app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
|
import { GithubReleaseSchema } from "@/shared/constants";
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
ROM_PLATFORMS: 'rom-platforms',
|
ROM_PLATFORMS: 'rom-platforms',
|
||||||
|
|
@ -31,4 +32,14 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return data;
|
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());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { activeGame, config, db, events, taskQueue } from "../app";
|
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
|
||||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
|
||||||
import z from "zod";
|
import z, { number } from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
|
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import path from "node:path";
|
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 buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||||
import { launchCommand } from "./services/launchGameService";
|
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
|
||||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||||
import { createJimp } from "@jimp/core";
|
import { createJimp } from "@jimp/core";
|
||||||
import webp from "@jimp/wasm-webp";
|
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
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -123,22 +128,52 @@ export default new Elysia()
|
||||||
})
|
})
|
||||||
.get('/games', async ({ query, set }) =>
|
.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[] = [];
|
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({
|
const localGames = await db.select({
|
||||||
...getTableColumns(schema.games),
|
...getTableColumns(schema.games),
|
||||||
platform: schema.platforms,
|
platform: schema.platforms,
|
||||||
|
|
@ -153,52 +188,30 @@ export default new Elysia()
|
||||||
.where(and(...where));
|
.where(and(...where));
|
||||||
|
|
||||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
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);
|
games.push(...localGames.map(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) =>
|
|
||||||
{
|
{
|
||||||
const system = path.dirname(e.path);
|
return convertLocalToFrontend(g);
|
||||||
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));
|
}
|
||||||
|
|
||||||
|
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 };
|
return { games };
|
||||||
|
|
@ -231,92 +244,59 @@ export default new Elysia()
|
||||||
})
|
})
|
||||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
.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({
|
if (sourceData.platform_slug)
|
||||||
where: match,
|
|
||||||
with: {
|
|
||||||
screenshots: { columns: { id: true } },
|
|
||||||
platform: { columns: { name: true, slug: true } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (localGame)
|
|
||||||
{
|
{
|
||||||
const exists = await checkInstalled(localGame.path_fs);
|
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||||
const fileSize = await calculateSize(localGame.path_fs);
|
if (systemMapping)
|
||||||
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 romGame = convertRomToFrontendDetailed(rom.data);
|
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
|
||||||
return romGame;
|
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");
|
return status("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ source: z.string(), id: z.string() })
|
params: z.object({ source: z.string(), id: z.string() })
|
||||||
})
|
})
|
||||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
.use(buildStatusResponse())
|
||||||
{
|
|
||||||
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() })
|
|
||||||
})
|
|
||||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
.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 });
|
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 } }) =>
|
.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')
|
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);
|
return status(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,7 +329,20 @@ export default new Elysia()
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
response: z.any()
|
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);
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
if (validCommands)
|
if (validCommands)
|
||||||
|
|
@ -362,11 +355,11 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
try
|
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)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
// launch command waits for the game to exit, we don't want that.
|
// 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 };
|
return { type: 'application', command: null };
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -382,7 +375,7 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
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() })
|
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
|
||||||
})
|
})
|
||||||
.post("/stop", async ({ }) =>
|
.post("/stop", async ({ }) =>
|
||||||
|
|
@ -404,4 +397,190 @@ export default new Elysia()
|
||||||
.get('/emulatorjs/data/*', async () =>
|
.get('/emulatorjs/data/*', async () =>
|
||||||
{
|
{
|
||||||
return status("Not Found");
|
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);
|
||||||
});
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||||
import z from "zod";
|
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 { db } from "../app";
|
||||||
import { FrontEndPlatformType } from "@shared/constants";
|
import { FrontEndPlatformType } from "@shared/constants";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
|
|
@ -25,17 +25,35 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
|
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 = {
|
const platform: FrontEndPlatformType = {
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
name: p.display_name,
|
name: p.display_name,
|
||||||
family_name: p.family_name,
|
family_name: p.family_name,
|
||||||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
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),
|
updated_at: new Date(p.updated_at),
|
||||||
id: { source: 'romm', id: String(p.id) },
|
id: { source: 'romm', id: String(p.id) },
|
||||||
hasLocal: localPlatformSet.has(p.slug),
|
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;
|
return platform;
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,18 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||||
import * as schema from '@schema/emulators';
|
import * as schema from '@schema/emulators';
|
||||||
import * as appSchema from "@schema/app";
|
import * as appSchema from "@schema/app";
|
||||||
import { eq } from 'drizzle-orm';
|
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 os from 'node:os';
|
||||||
import { $ } from 'bun';
|
import { $ } from 'bun';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
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 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)
|
if (activeGame && activeGame.process?.killed === false)
|
||||||
{
|
{
|
||||||
|
|
@ -31,8 +33,9 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
await new Promise((resolve, reject) =>
|
||||||
{
|
{
|
||||||
const game = spawn(validCommand, {
|
const game = spawn(validCommand.command, {
|
||||||
shell: true
|
shell: true,
|
||||||
|
cwd: validCommand.startDir
|
||||||
});
|
});
|
||||||
game.stdout.on('data', data => console.log(data));
|
game.stdout.on('data', data => console.log(data));
|
||||||
game.on('close', (code) =>
|
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: {
|
export async function getValidLaunchCommands (data: {
|
||||||
systemSlug: string;
|
systemSlug: string;
|
||||||
gamePath: 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;
|
return `"${arg
|
||||||
let cmd = command.command;
|
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
||||||
|
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
|
||||||
|
}"`;
|
||||||
|
}
|
||||||
|
|
||||||
let emulator: string | undefined = undefined;
|
const formattedCommands = await Promise.all(system.commands
|
||||||
let rom = validFiles[0];
|
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
|
||||||
|
.map(async (command, index) =>
|
||||||
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) =>
|
|
||||||
{
|
{
|
||||||
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);
|
// missing variable
|
||||||
return [[value, process.env[key]]];
|
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);
|
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) });
|
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||||
if (!emulator)
|
if (!emulator)
|
||||||
{
|
{
|
||||||
throw new Error(`Could not find emulator ${emulatorName}`);
|
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;
|
const regValues = emulator.winregistrypath;
|
||||||
if (regValues.length > 0)
|
if (regValues.length > 0)
|
||||||
|
|
@ -264,32 +365,32 @@ export async function findExec (emulator: { winregistrypath: string[], systempat
|
||||||
const registryValue = await readRegistryValue(node);
|
const registryValue = await readRegistryValue(node);
|
||||||
if (registryValue)
|
if (registryValue)
|
||||||
{
|
{
|
||||||
return { path: registryValue, type: 'registry' };
|
execs.push({ binPath: registryValue, type: 'registry', exists: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systempaths = emulator.systempath;
|
if (emulator && emulator.systempath.length > 0)
|
||||||
if (systempaths.length > 0)
|
|
||||||
{
|
{
|
||||||
const systemPath = await resolveSystemPath(systempaths);
|
const systemPath = await resolveSystemPath(emulator.systempath);
|
||||||
if (systemPath)
|
if (systemPath)
|
||||||
{
|
{
|
||||||
return { path: systemPath, type: 'system' };
|
execs.push({ binPath: systemPath, type: 'system', exists: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const staticPaths = emulator.staticpath;
|
if (emulator && emulator.staticpath.length > 0)
|
||||||
if (staticPaths.length > 0)
|
|
||||||
{
|
{
|
||||||
const staticPath = await resolveStaticPath(staticPaths);
|
const staticPath = await resolveStaticPath(emulator.staticpath);
|
||||||
if (staticPath)
|
if (staticPath)
|
||||||
{
|
{
|
||||||
return { path: staticPath, type: 'static' };
|
execs.push({ binPath: staticPath, type: 'static', exists: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return execs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readRegistryValue (text: string)
|
async function readRegistryValue (text: string)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { ErrorLike } from "elysia/universal";
|
||||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||||
import { cores } from "../../emulatorjs/emulatorjs";
|
import { cores } from "../../emulatorjs/emulatorjs";
|
||||||
import { host } from "@/bun/utils/host";
|
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
|
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}`;
|
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
|
||||||
commands.push({
|
commands.push({
|
||||||
id: 'emulatorjs',
|
id: 'EMULATORJS',
|
||||||
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: '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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function buildStatusResponse (source: string, id: string)
|
export default function buildStatusResponse ()
|
||||||
{
|
{
|
||||||
let cleanup: (() => void) | undefined;
|
return new Elysia().ws('/status/:source/:id', {
|
||||||
let closed = false;
|
response: z.discriminatedUnion('status', [
|
||||||
return new Response(new ReadableStream({
|
z.object({ status: z.literal('error'), error: z.unknown() }),
|
||||||
async start (controller)
|
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();
|
if (data === 'cancel')
|
||||||
|
|
||||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
|
||||||
{
|
{
|
||||||
if (closed) return;
|
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
activeTask?.abort('cancel');
|
||||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await sendLatests();
|
async open (ws)
|
||||||
|
{
|
||||||
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
sendLatests();
|
||||||
const keepAlive = setInterval(() =>
|
|
||||||
{
|
|
||||||
if (closed) return clearInterval(keepAlive);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
enqueue({}, 'ping');
|
|
||||||
} catch
|
|
||||||
{
|
|
||||||
closed = true;
|
|
||||||
clearInterval(keepAlive);
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const sourceId = `${source}-${id}`;
|
|
||||||
|
|
||||||
async function sendLatests ()
|
async function sendLatests ()
|
||||||
{
|
{
|
||||||
if (closed) return;
|
if (ws.readyState > 1) return;
|
||||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
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-${source}-${id}`);
|
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||||
if (activeTask)
|
if (activeTask)
|
||||||
{
|
{
|
||||||
enqueue({
|
if (activeTask.status === 'queued')
|
||||||
progress: activeTask.progress,
|
{
|
||||||
status: activeTask.state as any
|
ws.send({ status: 'queued' });
|
||||||
});
|
} else
|
||||||
|
{
|
||||||
|
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
||||||
|
}
|
||||||
|
|
||||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
} else if (activeGame && activeGame.gameId === localGame?.id)
|
||||||
{
|
{
|
||||||
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
|
ws.send({ status: 'playing', details: 'Playing' });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
if (validCommand instanceof Error)
|
if (validCommand instanceof Error)
|
||||||
{
|
{
|
||||||
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
|
ws.send({ status: 'error', error: validCommand.message });
|
||||||
}
|
}
|
||||||
else
|
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
|
// 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'));
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
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
|
} 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 fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||||
const size = Number(fileResponse.headers.get('content-length'));
|
const size = Number(fileResponse.headers.get('content-length'));
|
||||||
const stats = await fs.statfs(config.get('downloadPath'));
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
|
|
||||||
if (size > stats.bsize * stats.bavail)
|
if (size > stats.bsize * stats.bavail)
|
||||||
{
|
{
|
||||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||||
} else
|
} 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)
|
if (data.error)
|
||||||
{
|
{
|
||||||
enqueue({
|
ws.send({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: data.error
|
error: data.error
|
||||||
}, 'error');
|
});
|
||||||
}
|
}
|
||||||
await sendLatests();
|
await sendLatests();
|
||||||
};
|
};
|
||||||
events.on('activegameexit', handleActiveExit);
|
events.on('activegameexit', handleActiveExit);
|
||||||
dispose.push(() => events.off('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',
|
status: 'error',
|
||||||
error: getErrorMessage(error)
|
error: getErrorMessage(data.error)
|
||||||
}, 'error');
|
});
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cleanup = () =>
|
(ws.data as any).cleanup = () =>
|
||||||
{
|
{
|
||||||
closed = true;
|
|
||||||
dispose.forEach(f => f());
|
dispose.forEach(f => f());
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
cancel ()
|
close (ws, code, reason)
|
||||||
{
|
{
|
||||||
cleanup?.();
|
(ws.data as any).cleanup?.();
|
||||||
cleanup = undefined;
|
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import getFolderSize from "get-folder-size";
|
import getFolderSize from "get-folder-size";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { config, emulatorsDb } from "../../app";
|
import { config, db, emulatorsDb } from "../../app";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
|
||||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
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)
|
export async function calculateSize (installPath: string | null)
|
||||||
{
|
{
|
||||||
|
|
@ -127,7 +129,7 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
|
||||||
slug: null,
|
slug: null,
|
||||||
name: storeGame.title,
|
name: storeGame.title,
|
||||||
platform_id: null,
|
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)}`) ?? []
|
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;
|
return detailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
export async function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||||
{
|
{
|
||||||
const detailed: FrontEndGameTypeDetailed = {
|
const detailed: FrontEndGameTypeDetailed = {
|
||||||
...convertRomToFrontend(rom),
|
...convertRomToFrontend(rom),
|
||||||
summary: rom.summary,
|
summary: rom.summary,
|
||||||
fs_size_bytes: rom.fs_size_bytes,
|
fs_size_bytes: rom.fs_size_bytes,
|
||||||
local: false,
|
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)
|
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 = {
|
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
|
total: rom.merged_ra_metadata.achievements.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return detailed;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
105
src/bun/api/jobs/emulator-download-job.ts
Normal file
105
src/bun/api/jobs/emulator-download-job.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -6,12 +6,14 @@ import * as schema from "@schema/app";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||||
import { config, db, emulatorsDb, jar } from "../app";
|
import { config, db, emulatorsDb, events, jar } from "../app";
|
||||||
import unzip from 'unzip-stream';
|
|
||||||
import { Readable, Transform } from "node:stream";
|
|
||||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||||
import * as igdb from 'ts-igdb-client';
|
import * as igdb from 'ts-igdb-client';
|
||||||
import secrets from "../secrets";
|
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
|
interface JobConfig
|
||||||
{
|
{
|
||||||
|
|
@ -19,13 +21,16 @@ interface JobConfig
|
||||||
dryDownload?: boolean;
|
dryDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstallJob implements IJob
|
export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
||||||
|
export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
{
|
{
|
||||||
public gameId: string;
|
public gameId: string;
|
||||||
public source: string;
|
public source: string;
|
||||||
public sourceId: string;
|
public sourceId: string;
|
||||||
public config?: JobConfig;
|
public config?: JobConfig;
|
||||||
static id = "install-job" as const;
|
static id = "install-job" as const;
|
||||||
|
public group = InstallJob.id;
|
||||||
|
|
||||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||||
{
|
{
|
||||||
|
|
@ -35,162 +40,124 @@ export class InstallJob implements IJob
|
||||||
this.source = source;
|
this.source = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start (cx: JobContext)
|
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
|
||||||
{
|
{
|
||||||
cx.setProgress(0, 'download');
|
cx.setProgress(0, 'download');
|
||||||
fs.mkdir(config.get('downloadPath'), { recursive: true });
|
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)
|
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)
|
if (this.config?.dryDownload !== true)
|
||||||
{
|
{
|
||||||
/*
|
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
|
||||||
// download files for rom
|
files,
|
||||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
config.get('downloadPath'),
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
bytesReceived += chunk.length;
|
signal: cx.abortSignal,
|
||||||
if (totalBytes > 0)
|
onProgress (stats)
|
||||||
{
|
{
|
||||||
const percent = (bytesReceived / totalBytes) * 100;
|
cx.setProgress(stats.progress, 'download');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
Readable.fromWeb(res.body as any).pipe(progressStream)
|
|
||||||
.pipe(extract)
|
const downloadedFiles = await downloader.start();
|
||||||
.on('close', resolve)
|
if (extract_path && downloadedFiles)
|
||||||
.on('error', reject);
|
{
|
||||||
});
|
for (const path of downloadedFiles)
|
||||||
|
{
|
||||||
|
await _7z.unpack(path, extract_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config?.dryDownload === true)
|
if (this.config?.dryDownload === true)
|
||||||
|
|
@ -198,8 +165,6 @@ export class InstallJob implements IJob
|
||||||
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const coverResponse = await fetch(coverUrl);
|
const coverResponse = await fetch(coverUrl);
|
||||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
|
||||||
|
|
@ -291,7 +256,8 @@ export class InstallJob implements IJob
|
||||||
summary: summary,
|
summary: summary,
|
||||||
name,
|
name,
|
||||||
cover,
|
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 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import z, { } from "zod";
|
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
||||||
import { taskQueue } from "../app";
|
import { taskQueue } from "../app";
|
||||||
import { LoginJob } from "./login-job";
|
import { LoginJob } from "./login-job";
|
||||||
import TwitchLoginJob from "./twitch-login-job";
|
import TwitchLoginJob from "./twitch-login-job";
|
||||||
import UpdateStoreJob from "./update-store";
|
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', [
|
body: z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('cancel') })
|
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']),
|
type: z.literal(['data', 'started', 'progress']),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
progress: z.number(),
|
progress: z.number(),
|
||||||
data: dataSchema
|
data: _job.dataSchema
|
||||||
}),
|
}),
|
||||||
z.object({ type: z.literal(['completed', 'ended']) }),
|
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
|
||||||
z.object({ type: z.literal('error'), error: z.unknown() })
|
z.object({ type: z.literal('error'), error: z.string() })
|
||||||
]),
|
]),
|
||||||
open (ws)
|
open (ws)
|
||||||
{
|
{
|
||||||
const job = taskQueue.findJob(path);
|
const job = taskQueue.findJob(_job.id, _job);
|
||||||
if (job)
|
if (job)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
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 = [
|
(ws.data as any).cleanup = [
|
||||||
taskQueue.on('started', ({ id, job }) =>
|
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?.() });
|
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
taskQueue.on('progress', ({ id, job }) =>
|
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?.() });
|
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 }) =>
|
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')
|
if (message.type === 'cancel')
|
||||||
{
|
{
|
||||||
taskQueue.findJob(path)?.abort('cancel');
|
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
|
.use(registerJob(LoginJob))
|
||||||
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
|
.use(registerJob(TwitchLoginJob))
|
||||||
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
|
.use(registerJob(UpdateStoreJob))
|
||||||
|
.use(registerJob(EmulatorDownloadJob));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
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 { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||||
import { host, localIp } from "@/bun/utils/host";
|
import { host, localIp } from "@/bun/utils/host";
|
||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
|
|
@ -8,7 +8,7 @@ import { config } from "../app";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { delay } from "@/shared/utils";
|
import { delay } from "@/shared/utils";
|
||||||
|
|
||||||
export class LoginJob implements IJob
|
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
|
||||||
{
|
{
|
||||||
endsAt: Date;
|
endsAt: Date;
|
||||||
startedAt: 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 });
|
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 } })
|
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||||
.use(cors())
|
.use(cors())
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ interface TwitchDevice
|
||||||
verification_uri: string;
|
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";
|
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||||
device?: TwitchDevice;
|
device?: TwitchDevice;
|
||||||
|
|
@ -38,7 +40,7 @@ export default class TwitchLoginJob implements IJob
|
||||||
user_code: this.device.user_code
|
user_code: this.device.user_code
|
||||||
}) : undefined;
|
}) : 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");
|
context.setProgress(0, "Retrieving Device");
|
||||||
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { IJob, JobContext } from "../task-queue";
|
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 id = "update-store" as const;
|
||||||
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
||||||
static branch = "master";
|
static branch = "master";
|
||||||
|
static dataSchema = z.never();
|
||||||
|
|
||||||
async gitCommand (commands: string[], dir: string)
|
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;
|
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();
|
const storeFolder = getStoreFolder();
|
||||||
await ensureDir(storeFolder);
|
await ensureDir(storeFolder);
|
||||||
context.setProgress(10);
|
context.setProgress(10);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const emulators = sqliteTable('emulators', {
|
export const emulators = sqliteTable('emulators', {
|
||||||
name: text().primaryKey().unique(),
|
name: text().primaryKey().unique(),
|
||||||
|
fullname: text(),
|
||||||
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||||
staticpath: 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())`),
|
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import { findExecByName } from "../games/services/launchGameService";
|
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { customEmulators, db, emulatorsDb } from '../app';
|
import { customEmulators, db, emulatorsDb } from '../app';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { cores } from '../emulatorjs/emulatorjs';
|
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.
|
* 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 groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||||
{
|
{
|
||||||
let execPath: { path: string; type: string, } | undefined;
|
const execPaths = await findExecsByName(emulator);
|
||||||
if (customEmulators.has(emulator))
|
const validExecPath = execPaths.find(e => e.exists);
|
||||||
{
|
|
||||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
execPath = await findExecByName(emulator);
|
|
||||||
}
|
|
||||||
|
|
||||||
let platform: number | null | undefined = null;
|
let platform: number | null | undefined = null;
|
||||||
const validSystemSlug = system_slug.find(s => s.system);
|
const validSystemSlug = system_slug.find(s => s.system);
|
||||||
|
|
@ -68,45 +63,31 @@ export async function getRelevantEmulators ()
|
||||||
{
|
{
|
||||||
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
|
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!)));
|
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));
|
systems.forEach(s => platformViability.set(s, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = {
|
const em: FrontEndEmulator & { isCritical: boolean; } = {
|
||||||
name: emulator,
|
name: emulator,
|
||||||
exists: exists,
|
|
||||||
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
||||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
||||||
gameCount: 0,
|
gameCount: 0,
|
||||||
description: '',
|
|
||||||
homepage: '',
|
|
||||||
type: 'emulator',
|
|
||||||
os: [process.platform as any],
|
|
||||||
isCritical: false,
|
isCritical: false,
|
||||||
path: execPath,
|
validSource: validExecPath
|
||||||
};
|
};
|
||||||
|
|
||||||
return em;
|
return em;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
finalEmulators.push({
|
finalEmulators.push({
|
||||||
name: 'emulatorjs',
|
name: 'EMULATORJS',
|
||||||
exists: true,
|
validSource: { binPath: `${SERVER_URL(host)}`, type: 'js', exists: true },
|
||||||
path: { path: 'localhost', type: 'js' },
|
|
||||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||||
systems: [],
|
systems: [],
|
||||||
gameCount: 0,
|
gameCount: 0,
|
||||||
type: 'emulator',
|
isCritical: false,
|
||||||
description: '',
|
|
||||||
homepage: '',
|
|
||||||
os: [process.platform as any],
|
|
||||||
isCritical: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return finalEmulators.map(e =>
|
return finalEmulators.map(e =>
|
||||||
|
|
|
||||||
31
src/bun/api/store/services/emulatorsService.ts
Normal file
31
src/bun/api/store/services/emulatorsService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 { 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 ()
|
export async function getStoreGameManifest ()
|
||||||
{
|
{
|
||||||
|
|
@ -56,4 +73,55 @@ export async function getStoreGameFromPath (path: string)
|
||||||
.then(e => e.json())
|
.then(e => e.json())
|
||||||
.then(g => StoreGameSchema.parseAsync(g)));
|
.then(g => StoreGameSchema.parseAsync(g)));
|
||||||
return game;
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1,61 +1,19 @@
|
||||||
|
|
||||||
import Elysia from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, customEmulators, db } from "../app";
|
import { config, db, taskQueue } from "../app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
|
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
|
||||||
import { findExec } from "../games/services/launchGameService";
|
import { findExecsByName } from "../games/services/launchGameService";
|
||||||
import { emulatorsDb } from '../app';
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
||||||
|
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
|
||||||
export function getStoreFolder ()
|
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||||
{
|
import { Glob } from "bun";
|
||||||
const downlodDir = config.get('downloadPath');
|
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const store = new Elysia({ prefix: '/api/store' })
|
export const store = new Elysia({ prefix: '/api/store' })
|
||||||
.get('/emulators', async ({ query }) =>
|
.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))
|
.filter(e => e.os.includes(process.platform as any))
|
||||||
.map(async (emulator) =>
|
.map(async (emulator) =>
|
||||||
{
|
{
|
||||||
let execPath: { path: string; type: string; } | undefined;
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
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 gameCounts = await Promise.all(systems.map(async (s) =>
|
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 === (s.romm_slug ?? s.id));
|
||||||
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
|
|
||||||
if (romPlatform)
|
if (romPlatform)
|
||||||
{
|
{
|
||||||
return romPlatform.rom_count;
|
return romPlatform.rom_count;
|
||||||
|
|
@ -101,13 +42,12 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
const gameCount = gameCounts.reduce((a, c) => a + c);
|
||||||
|
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
|
||||||
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (query.missing)
|
if (query.missing)
|
||||||
{
|
{
|
||||||
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
|
frontEndEmulators = frontEndEmulators.filter(e => !e.validSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.orderBy === 'importance')
|
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));
|
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
||||||
},
|
},
|
||||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
{ 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 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 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 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 = {
|
const emulator: FrontEndEmulatorDetailed = {
|
||||||
...emulatorPackage,
|
name: emulatorPackage.name,
|
||||||
|
description: emulatorPackage.description,
|
||||||
systems,
|
systems,
|
||||||
exists,
|
validSource: validExec,
|
||||||
status: {
|
|
||||||
source: execPath?.type,
|
|
||||||
location: execPath?.path
|
|
||||||
},
|
|
||||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
|
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;
|
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");
|
||||||
|
});
|
||||||
|
|
@ -11,7 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
||||||
import { getDevices, getDevicesCurated } from "./drives";
|
import { getDevices, getDevicesCurated } from "./drives";
|
||||||
import getFolderSize from "get-folder-size";
|
import getFolderSize from "get-folder-size";
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import { getStoreFolder } from "./store/store";
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
|
|
||||||
export const system = new Elysia({ prefix: '/api/system' })
|
export const system = new Elysia({ prefix: '/api/system' })
|
||||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,44 @@
|
||||||
|
|
||||||
|
import { JobStatus } from '@/shared/constants';
|
||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
|
import z, { ZodTypeAny } from 'zod';
|
||||||
|
|
||||||
export class TaskQueue
|
export class TaskQueue
|
||||||
{
|
{
|
||||||
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
|
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||||
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
|
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
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();
|
this.disposeSafeguard();
|
||||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||||
const context = new JobContext(id, this.events, job);
|
const context = new JobContext(id, this.events, job);
|
||||||
this.queue.push({ context });
|
this.queue.push({ context });
|
||||||
|
this.events?.emit('queued', { id: context.id, job: context });
|
||||||
return this.processQueue();
|
return this.processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private processQueue (): Promise<void>
|
private processQueue ()
|
||||||
{
|
{
|
||||||
if (!this.queue) return Promise.resolve();
|
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();
|
const promise = job.job.context.start();
|
||||||
top.promise = promise;
|
job.job.promise = promise;
|
||||||
const index = this.queue.length;
|
this.activeQueue.push(job.job);
|
||||||
this.activeQueue.push(top);
|
|
||||||
promise.finally(() =>
|
promise.finally(() =>
|
||||||
{
|
{
|
||||||
|
const index = this.activeQueue.indexOf(job.job);
|
||||||
this.activeQueue.splice(index, 1);
|
this.activeQueue.splice(index, 1);
|
||||||
setTimeout(this.processQueue);
|
setTimeout(() => this.processQueue(), 0);
|
||||||
});
|
});
|
||||||
return promise;
|
});
|
||||||
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private disposeSafeguard ()
|
private disposeSafeguard ()
|
||||||
|
|
@ -65,10 +69,15 @@ export class TaskQueue
|
||||||
return job?.promise ?? Promise.resolve();
|
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);
|
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
|
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];
|
completed: [e: CompletedEvent];
|
||||||
error: [e: ErrorEvent];
|
error: [e: ErrorEvent];
|
||||||
ended: [e: BaseEvent];
|
ended: [e: BaseEvent];
|
||||||
|
queued: [e: BaseEvent];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseEvent
|
interface BaseEvent
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
job: IPublicJob;
|
job: IPublicJob<any, string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorEvent extends BaseEvent
|
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>;
|
group?: string;
|
||||||
exposeData?(): any;
|
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
||||||
|
exposeData?(): TData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
export interface IPublicJob<TData, TState extends string, T extends IJob<TData, TState>>
|
||||||
|
|
||||||
export interface IPublicJob
|
|
||||||
{
|
{
|
||||||
progress: number;
|
progress: number;
|
||||||
state?: string;
|
state?: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
job: IJob;
|
job: T;
|
||||||
abort: (reason?: any) => void;
|
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_id: string;
|
||||||
private m_progress: number = 0;
|
private m_progress: number = 0;
|
||||||
private m_state?: string;
|
private m_state?: TState;
|
||||||
private running: boolean = false;
|
private running: boolean = false;
|
||||||
private aborted: boolean = false;
|
private aborted: boolean = false;
|
||||||
private completed: boolean = false;
|
private completed: boolean = false;
|
||||||
private error?: any;
|
private error?: any;
|
||||||
private events: EventEmitter<EventsList>;
|
private events: EventEmitter<EventsList>;
|
||||||
private abortController: AbortController;
|
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_id = id;
|
||||||
this.m_job = job;
|
this.m_job = job;
|
||||||
|
|
@ -202,7 +225,7 @@ export class JobContext implements IPublicJob
|
||||||
if (this.error) return 'error';
|
if (this.error) return 'error';
|
||||||
if (this.aborted) return 'aborted';
|
if (this.aborted) return 'aborted';
|
||||||
if (this.running) return 'running';
|
if (this.running) return 'running';
|
||||||
return 'waiting';
|
return 'queued';
|
||||||
}
|
}
|
||||||
|
|
||||||
public get id () { return this.m_id; }
|
public get id () { return this.m_id; }
|
||||||
|
|
@ -215,7 +238,11 @@ export class JobContext implements IPublicJob
|
||||||
|
|
||||||
public get state () { return this.m_state; }
|
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;
|
this.m_progress = progress;
|
||||||
if (state)
|
if (state)
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import { createInterface } from 'readline';
|
||||||
const api = RunAPIServer();
|
const api = RunAPIServer();
|
||||||
let bunServer: { stop: () => void; } | undefined;
|
let bunServer: { stop: () => void; } | undefined;
|
||||||
|
|
||||||
if (!Bun.env.PUBLIC_ACCESS)
|
if (!process.env.PUBLIC_ACCESS)
|
||||||
{
|
{
|
||||||
bunServer = RunBunServer();
|
bunServer = await RunBunServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanup ()
|
async function cleanup ()
|
||||||
|
|
@ -24,7 +24,7 @@ async function cleanup ()
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Bun.env.HEADLESS)
|
if (process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
const rl = createInterface({ input: process.stdin });
|
const rl = createInterface({ input: process.stdin });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import staticPlugin from "@elysiajs/static";
|
||||||
export function RunBunServer ()
|
export function RunBunServer ()
|
||||||
{
|
{
|
||||||
console.log("Launching Server on port ", SERVER_PORT);
|
console.log("Launching Server on port ", SERVER_PORT);
|
||||||
return new Elysia()
|
const server = new Elysia()
|
||||||
.use(cors())
|
.use(cors())
|
||||||
.headers({
|
.headers({
|
||||||
'cross-origin-embedder-policy': 'credentialless',
|
'cross-origin-embedder-policy': 'credentialless',
|
||||||
|
|
@ -28,33 +28,11 @@ export function RunBunServer ()
|
||||||
assets: appPath("./dist"),
|
assets: appPath("./dist"),
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
alwaysStatic: true
|
alwaysStatic: true
|
||||||
})).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
}));
|
||||||
/*return Bun.serve({
|
|
||||||
port: SERVER_PORT,
|
return new Promise<typeof server>((resolve) =>
|
||||||
hostname: host,
|
{
|
||||||
routes: {
|
server.onStart(() => resolve(server))
|
||||||
"/": Bun.file(appPath("./dist/index.html")),
|
.listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
||||||
// 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)}`)));
|
|
||||||
},
|
|
||||||
});*/
|
|
||||||
}
|
}
|
||||||
2
src/bun/types/types.d.ts
vendored
2
src/bun/types/types.d.ts
vendored
|
|
@ -6,7 +6,7 @@ export type ActiveGame = {
|
||||||
process?: ChildProcess;
|
process?: ChildProcess;
|
||||||
gameId: number;
|
gameId: number;
|
||||||
name: string;
|
name: string;
|
||||||
command: string;
|
command: { command: string, startDir?: string; };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ObjectConstructor
|
interface ObjectConstructor
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { $ } from 'bun';
|
import { $ } from 'bun';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
|
||||||
export function checkRunning (pid: number)
|
export function checkRunning (pid: number)
|
||||||
{
|
{
|
||||||
|
|
@ -68,4 +70,44 @@ export async function openExternal (target: string)
|
||||||
{
|
{
|
||||||
return $`open ${target}`.throws(true);
|
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
222
src/bun/utils/downloader.ts
Normal 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
|
|
@ -33,6 +33,7 @@ export interface GameCardParams
|
||||||
onFocus?: GameCardFocusHandler;
|
onFocus?: GameCardFocusHandler;
|
||||||
onBlur?: (id: string) => void;
|
onBlur?: (id: string) => void;
|
||||||
clickFocuses?: boolean;
|
clickFocuses?: boolean;
|
||||||
|
previewClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CardElement (data: GameCardParams & InteractParams)
|
export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
|
|
@ -53,7 +54,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
role="button"
|
role="button"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{
|
style={{
|
||||||
scrollSnapAlign: "center"
|
scrollSnapAlign: isPointer ? "center" : "none"
|
||||||
}}
|
}}
|
||||||
onFocus={focusSelf}
|
onFocus={focusSelf}
|
||||||
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
|
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
|
||||||
|
|
@ -74,7 +75,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
classNames({ "h-full": typeof data.preview === "string" })
|
classNames({ "h-full": typeof data.preview === "string" })
|
||||||
)}>
|
)}>
|
||||||
{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
|
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ import { RPC_URL } from "@/shared/constants";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { CardList, GameMetaExtra } from "./CardList";
|
import { CardList, GameMetaExtra } from "./CardList";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
import { GameCardFocusHandler } from "./CardElement";
|
||||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
import { getCollectionsQuery } from "@queries/romm";
|
||||||
import queries from "../scripts/queries";
|
|
||||||
|
|
||||||
export default function CollectionList (data: {
|
export default function CollectionList (data: {
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -17,12 +15,11 @@ export default function CollectionList (data: {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
|
const { data: collections } = useSuspenseQuery(getCollectionsQuery());
|
||||||
|
|
||||||
const handleDefaultSelect = (id: string) =>
|
const handleDefaultSelect = (id: string) =>
|
||||||
{
|
{
|
||||||
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
|
navigate({ to: `/collection/${id}` });
|
||||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -36,7 +33,7 @@ export default function CollectionList (data: {
|
||||||
id: String(g.id),
|
id: String(g.id),
|
||||||
title: g.name,
|
title: g.name,
|
||||||
focusKey: `collection-${g.id}`,
|
focusKey: `collection-${g.id}`,
|
||||||
subtitle: g.user__username,
|
subtitle: g.owner_username,
|
||||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
||||||
badges: [
|
badges: [
|
||||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
|
||||||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
||||||
import { GameListFilterType } from '@/shared/constants';
|
import { GameListFilterType } from '@/shared/constants';
|
||||||
import { GameCardFocusHandler } from './CardElement';
|
import { GameCardFocusHandler } from './CardElement';
|
||||||
|
import { Router } from '..';
|
||||||
|
import { HandleGoBack } from '../scripts/utils';
|
||||||
|
|
||||||
export interface CollectionsDetailParams
|
export interface CollectionsDetailParams
|
||||||
{
|
{
|
||||||
|
|
@ -30,7 +32,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
preferredChildFocusKey: `${focusKey}-list`,
|
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 { shortcuts } = useShortcutContext();
|
||||||
|
|
||||||
const handleScroll: GameCardFocusHandler = (id, node, details) =>
|
const handleScroll: GameCardFocusHandler = (id, node, details) =>
|
||||||
|
|
|
||||||
|
|
@ -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 classNames from "classnames";
|
||||||
import { JSX, useContext, useEffect } from "react";
|
import { JSX, useContext, useEffect, useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||||
|
|
@ -67,21 +67,61 @@ export interface DialogEntry
|
||||||
shortcuts?: Shortcut[];
|
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: {
|
export function ContextDialog (data: {
|
||||||
id: string,
|
id: string,
|
||||||
children: any | any[],
|
children: any | any[],
|
||||||
open: boolean,
|
open: boolean,
|
||||||
close: () => void;
|
close: (open: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
preferredChildFocusKey?: string;
|
preferredChildFocusKey?: string;
|
||||||
|
sourceFocusKey?: string;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
focusable: data.open,
|
focusable: data.open,
|
||||||
focusKey: `${data.id}-context-dialog`,
|
focusKey: `${data.id}-context-dialog`,
|
||||||
isFocusBoundary: true,
|
isFocusBoundary: true,
|
||||||
|
saveLastFocusedChild: !data.preferredChildFocusKey,
|
||||||
preferredChildFocusKey: data.preferredChildFocusKey
|
preferredChildFocusKey: data.preferredChildFocusKey
|
||||||
});
|
});
|
||||||
|
const handleClose = () =>
|
||||||
|
{
|
||||||
|
data.close(false);
|
||||||
|
};
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if (data.open)
|
if (data.open)
|
||||||
|
|
@ -93,22 +133,16 @@ export function ContextDialog (data: {
|
||||||
useShortcuts(focusKey, () => data.open ? [{
|
useShortcuts(focusKey, () => data.open ? [{
|
||||||
label: "Close",
|
label: "Close",
|
||||||
button: GamePadButtonCode.B,
|
button: GamePadButtonCode.B,
|
||||||
action: () =>
|
action: handleClose
|
||||||
{
|
|
||||||
data.close();
|
|
||||||
}
|
|
||||||
}] : [], [data.open]);
|
}] : [], [data.open]);
|
||||||
|
|
||||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
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",
|
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 }))
|
classNames({ "opacity-0": !data.open }))
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={handleClose}>
|
||||||
{
|
|
||||||
if (data.open) data.close();
|
|
||||||
}}>
|
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
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",
|
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
|
||||||
import SvgIcon from "./SvgIcon";
|
import SvgIcon from "./SvgIcon";
|
||||||
import { Button } from "./options/Button";
|
import { Button } from "./options/Button";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import queries from "../scripts/queries";
|
|
||||||
import { FilePickerContext } from "../scripts/contexts";
|
import { FilePickerContext } from "../scripts/contexts";
|
||||||
import useActiveControl from "../scripts/gamepads";
|
import useActiveControl from "../scripts/gamepads";
|
||||||
|
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
|
||||||
|
|
||||||
function List (data: {
|
function List (data: {
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -113,7 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
||||||
const { refetchFiles } = useContext(FilePickerContext);
|
const { refetchFiles } = useContext(FilePickerContext);
|
||||||
const [name, setName] = useState<string | undefined>();
|
const [name, setName] = useState<string | undefined>();
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...queries.system.createFolderMutation(data.id),
|
...createFolderMutation(data.id),
|
||||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||||
onSuccess: (d, v, r, cx) =>
|
onSuccess: (d, v, r, cx) =>
|
||||||
{
|
{
|
||||||
|
|
@ -228,8 +228,8 @@ export default function FilePicker (data: {
|
||||||
{
|
{
|
||||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||||
|
|
||||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
|
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
|
||||||
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery);
|
const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
|
||||||
|
|
||||||
const fullPath = files ? path.join(files.parentPath, files.name) : '';
|
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];
|
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
FocusContext,
|
FocusContext,
|
||||||
|
setFocus,
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import SvgIcon from "./SvgIcon";
|
import SvgIcon from "./SvgIcon";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||||
|
|
||||||
function FilterCat (
|
function FilterCat (
|
||||||
data: {
|
data: {
|
||||||
id: string;
|
id: string;
|
||||||
children?: any;
|
children?: any;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
hasFocusedPeer: boolean;
|
|
||||||
} & FilterOption & FocusParams,
|
} & FilterOption & FocusParams,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
@ -26,9 +28,10 @@ function FilterCat (
|
||||||
aria-selected={data.active}
|
aria-selected={data.active}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={focusSelf}
|
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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +42,8 @@ export function FilterUI (data: {
|
||||||
setSelected: (id: string) => void;
|
setSelected: (id: string) => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
rootFocusKey?: string;
|
||||||
|
showShortcuts?: boolean;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
|
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
|
||||||
|
|
@ -50,29 +55,72 @@ export function FilterUI (data: {
|
||||||
trackChildren: true
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={data.containerClassName}
|
className={data.containerClassName}
|
||||||
|
style={{ viewTransitionName: `filter-${data.id}` }}
|
||||||
>
|
>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<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)}>
|
<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" />
|
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||||
</li>
|
</li>}
|
||||||
{Object.entries(data.options)?.map(([id, option]) => (
|
{Object.entries(data.options)?.map(([id, option]) => (
|
||||||
<FilterCat
|
<FilterCat
|
||||||
hasFocusedPeer={hasFocusedChild}
|
|
||||||
id={`${data.id}-${id}`}
|
id={`${data.id}-${id}`}
|
||||||
key={id}
|
key={id}
|
||||||
onFocus={() => data.setSelected(id)}
|
onFocus={() =>
|
||||||
|
{
|
||||||
|
if (!option.selected)
|
||||||
|
data.setSelected(id);
|
||||||
|
}}
|
||||||
active={option.selected}
|
active={option.selected}
|
||||||
{...option}
|
{...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" />
|
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_r1_outline" />
|
||||||
</li>
|
</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ export default function FocusDots (data: {
|
||||||
scrollElement?: RefObject<HTMLElement | null>;
|
scrollElement?: RefObject<HTMLElement | null>;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
|
|
||||||
const focusedKey = useGlobalFocus();
|
const focusedKey = useGlobalFocus();
|
||||||
let elements = useMemo(() =>
|
let elements = useMemo(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -62,7 +61,7 @@ export default function FocusDots (data: {
|
||||||
|
|
||||||
return childrenArray.map((c, i) =>
|
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
|
} else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
||||||
import CardElement from "./CardElement";
|
import CardElement from "./CardElement";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
import { HardDrive } from "lucide-react";
|
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
import { FOCUS_KEYS } from "../scripts/types";
|
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)
|
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||||
{
|
{
|
||||||
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
|
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||||
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'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
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");
|
previewUrl.searchParams.set('width', "640");
|
||||||
|
|
||||||
const badges: JSX.Element[] = [];
|
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" />);
|
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}
|
preview={previewUrl.href}
|
||||||
title={data.game.name ?? ""}
|
title={data.game.name ?? ""}
|
||||||
subtitle={subtitle}
|
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}
|
index={data.index}
|
||||||
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
||||||
/>;
|
/>;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { GameMetaExtra, CardList } from "./CardList";
|
import { GameMetaExtra, CardList } from "./CardList";
|
||||||
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||||
import { HardDrive } from "lucide-react";
|
|
||||||
import { JSX, useContext } from "react";
|
import { JSX, useContext } from "react";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
import { GameCardFocusHandler } from "./CardElement";
|
||||||
import { useLocalSetting } from "../scripts/utils";
|
import { useLocalSetting } from "../scripts/utils";
|
||||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||||
import queries from "../scripts/queries";
|
import { allGamesQuery } from "@queries/romm";
|
||||||
|
|
||||||
export interface GameListParams
|
export interface GameListParams
|
||||||
{
|
{
|
||||||
|
|
@ -25,7 +24,7 @@ export interface GameListParams
|
||||||
|
|
||||||
export function GameList (data: GameListParams)
|
export function GameList (data: GameListParams)
|
||||||
{
|
{
|
||||||
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
|
const games = useSuspenseQuery(allGamesQuery(data.filters));
|
||||||
const navigator = useNavigate();
|
const navigator = useNavigate();
|
||||||
const blur = useLocalSetting('backgroundBlur');
|
const blur = useLocalSetting('backgroundBlur');
|
||||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||||
|
|
@ -51,8 +50,7 @@ export function GameList (data: GameListParams)
|
||||||
|
|
||||||
function handleDefaultSelect (g: FrontEndGameType)
|
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 } });
|
||||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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" />);
|
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}`);
|
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||||
previewUrl.searchParams.delete('ts');
|
previewUrl.searchParams.delete('ts');
|
||||||
previewUrl.searchParams.set('width', "16");
|
previewUrl.searchParams.set('width', "16");
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,10 @@ import { RoundButton } from "./RoundButton";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { RPC_URL } from "../../shared/constants";
|
||||||
import { JSX, useEffect, useRef } from "react";
|
import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
|
||||||
import { systemApi } from "../scripts/clientApi";
|
import { systemApi } from "../scripts/clientApi";
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
|
import { useStickyDataAttr } from "../scripts/utils";
|
||||||
|
|
||||||
function HeaderAvatar (data: {
|
function HeaderAvatar (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -240,8 +240,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
],
|
],
|
||||||
action: () =>
|
action: () =>
|
||||||
{
|
{
|
||||||
SaveSource('settings');
|
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
||||||
Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
|
||||||
},
|
},
|
||||||
status: user.data ? "status-success" : 'status-error',
|
status: user.data ? "status-success" : 'status-error',
|
||||||
type: 'secondary'
|
type: 'secondary'
|
||||||
|
|
@ -284,15 +283,19 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderUI (data: {
|
interface HeaderUIParams
|
||||||
|
{
|
||||||
buttons?: HeaderButton[];
|
buttons?: HeaderButton[];
|
||||||
accounts?: HeaderAccount[];
|
accounts?: HeaderAccount[];
|
||||||
buttonElements?: JSX.Element[] | JSX.Element;
|
buttonElements?: JSX.Element[] | JSX.Element;
|
||||||
title?: JSX.Element;
|
title?: JSX.Element;
|
||||||
preferredChildFocusKey?: string;
|
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 (
|
return (
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<header
|
<header
|
||||||
|
|
@ -307,3 +310,18 @@ export function HeaderUI (data: {
|
||||||
</FocusContext.Provider>
|
</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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { FOCUS_KEYS } from "../scripts/types";
|
import { FOCUS_KEYS } from "../scripts/types";
|
||||||
import { useIntersectionObserver } from "usehooks-ts";
|
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) =>
|
const handleAction = (e?: Event) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Notification, RPC_URL } from "@/shared/constants";
|
import { Notification, RPC_URL } from "@/shared/constants";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast, { ToastOptions } from "react-hot-toast";
|
||||||
|
|
||||||
export default function Notifications (data: {})
|
export default function Notifications (data: {})
|
||||||
{
|
{
|
||||||
|
|
@ -10,15 +10,16 @@ export default function Notifications (data: {})
|
||||||
es.addEventListener('notification', (e) =>
|
es.addEventListener('notification', (e) =>
|
||||||
{
|
{
|
||||||
const notification = JSON.parse(e.data) as Notification;
|
const notification = JSON.parse(e.data) as Notification;
|
||||||
|
const options: ToastOptions = { removeDelay: notification.duration };
|
||||||
if (notification.type === 'error')
|
if (notification.type === 'error')
|
||||||
{
|
{
|
||||||
toast.error(notification.message);
|
toast.error(notification.message, options);
|
||||||
} else if (notification.type === 'success')
|
} else if (notification.type === 'success')
|
||||||
{
|
{
|
||||||
toast.success(notification.message);
|
toast.success(notification.message, options);
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
toast.custom(notification.message);
|
toast.custom(notification.message, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router";
|
||||||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||||
import { CardList, GameMetaExtra } from "./CardList";
|
import { CardList, GameMetaExtra } from "./CardList";
|
||||||
import { rommApi } from "../scripts/clientApi";
|
import { rommApi } from "../scripts/clientApi";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
|
||||||
import { JSX, useMemo } from "react";
|
import { JSX, useMemo } from "react";
|
||||||
import { HardDrive } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
import { GameCardFocusHandler } from "./CardElement";
|
||||||
|
|
@ -37,8 +36,7 @@ export function PlatformsList (data: {
|
||||||
|
|
||||||
const handleDefaultSelect = (source: string, id: string) =>
|
const handleDefaultSelect = (source: string, id: string) =>
|
||||||
{
|
{
|
||||||
SaveSource('game-list');
|
navigate({ to: `/platform/${source}/${id}` });
|
||||||
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
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 FocusDots from "./FocusDots";
|
||||||
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
||||||
import { Fullscreen } from "lucide-react";
|
import { Fullscreen } from "lucide-react";
|
||||||
import Carousel from "./Carousel";
|
import Carousel from "./Carousel";
|
||||||
import { ContextDialog } from "./ContextDialog";
|
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)
|
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>;
|
</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 [preview, setPreview] = useState<number | undefined>(undefined);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -41,9 +78,10 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if ((focused || hasFocusedChild) && scrollRef.current)
|
if ((focused || hasFocusedChild) && scrollRef.current && data.screenshots)
|
||||||
{
|
{
|
||||||
const closest = findClosestElementToCenter(scrollRef.current);
|
const closest = findClosestElementToCenter(scrollRef.current);
|
||||||
|
if (!closest) return;
|
||||||
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
||||||
setFocus(`screenshot-${closestIndex}`);
|
setFocus(`screenshot-${closestIndex}`);
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +92,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
||||||
const center = element.scrollLeft + element.clientWidth / 2;
|
const center = element.scrollLeft + element.clientWidth / 2;
|
||||||
|
|
||||||
const children = Array.from(element.children) as HTMLElement[];
|
const children = Array.from(element.children) as HTMLElement[];
|
||||||
|
if (children.length <= 0) return undefined;
|
||||||
|
|
||||||
// find child closest to center
|
// find child closest to center
|
||||||
return children.reduce((closest, child) =>
|
return children.reduce((closest, child) =>
|
||||||
|
|
@ -78,7 +117,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
||||||
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
||||||
{
|
{
|
||||||
const current = findClosestElementToCenter(element);
|
const current = findClosestElementToCenter(element);
|
||||||
|
if (!current) return;
|
||||||
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
||||||
if (!next) return;
|
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);
|
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}>
|
<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" >
|
<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>
|
</Carousel>
|
||||||
<FocusDots scrollElement={scrollRef} />
|
<FocusDots scrollElement={scrollRef} />
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
||||||
{
|
{
|
||||||
setFocus(`screenshot-${preview}`);
|
setFocus(`screenshot-${preview}`, { instant: true });
|
||||||
setPreview(undefined);
|
setPreview(undefined);
|
||||||
}} open={true}>
|
}} 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>}
|
</ContextDialog>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
50
src/mainview/components/StatList.tsx
Normal file
50
src/mainview/components/StatList.tsx
Normal 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>;
|
||||||
|
}
|
||||||
35
src/mainview/components/game/Achievements.tsx
Normal file
35
src/mainview/components/game/Achievements.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import queries from "@/mainview/scripts/queries";
|
import { changeDownloadsMutation } from "@queries/settings";
|
||||||
|
|
||||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||||
{
|
{
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const setSettingMutation = useMutation({
|
const setSettingMutation = useMutation({
|
||||||
...queries.settings.changeDownloadsMutation,
|
...changeDownloadsMutation,
|
||||||
onSuccess: (d, v, r, cx) =>
|
onSuccess: (d, v, r, cx) =>
|
||||||
{
|
{
|
||||||
setDirty(r !== localValue);
|
setDirty(r !== localValue);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
|
||||||
import { ContextDialog } from "../ContextDialog";
|
import { ContextDialog } from "../ContextDialog";
|
||||||
import FilePicker from "../FilePicker";
|
import FilePicker from "../FilePicker";
|
||||||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import queries from "@/mainview/scripts/queries";
|
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||||
|
|
||||||
type KeysWithValueAssignableTo<T, Value> = {
|
type KeysWithValueAssignableTo<T, Value> = {
|
||||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
[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 [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const setMutation = useMutation({
|
const setMutation = useMutation({
|
||||||
...queries.settings.setSettingMutation(data.id),
|
...setSettingMutation(data.id),
|
||||||
onSuccess: (d, v, r, cx) =>
|
onSuccess: (d, v, r, cx) =>
|
||||||
{
|
{
|
||||||
setDirty(r !== localValue);
|
setDirty(r !== localValue);
|
||||||
|
|
@ -63,7 +63,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
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;
|
const changed = defaultValue !== data.localValue;
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { SettingsType } from "../../../shared/constants";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { OptionSpace } from "./OptionSpace";
|
import { OptionSpace } from "./OptionSpace";
|
||||||
import { OptionInput } from "./OptionInput";
|
import { OptionInput } from "./OptionInput";
|
||||||
import queries from "@/mainview/scripts/queries";
|
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||||
|
|
||||||
type KeysWithValueAssignableTo<T, Value> = {
|
type KeysWithValueAssignableTo<T, Value> = {
|
||||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
[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 [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
useQuery(queries.settings.getSettingQuery(data.id));
|
useQuery(getSettingQuery(data.id));
|
||||||
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
|
const setMutation = useMutation(setSettingMutation(data.id));
|
||||||
|
|
||||||
const handleSave = useCallback(() =>
|
const handleSave = useCallback(() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function EmulatorsSection (data: {
|
||||||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||||
}} />
|
}} />
|
||||||
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
|
)) ?? 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>
|
</Carousel>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,58 @@
|
||||||
import { useRef } from "react";
|
import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
useFocusable,
|
useFocusable,
|
||||||
FocusContext,
|
FocusContext,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { Gamepad2, Star } from "lucide-react";
|
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||||
import { useDragScroll } from "@/mainview/scripts/utils";
|
|
||||||
import FocusDots from "../FocusDots";
|
import FocusDots from "../FocusDots";
|
||||||
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
||||||
import FrontEndGameCard from "../FrontEndGameCard";
|
import FrontEndGameCard from "../FrontEndGameCard";
|
||||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
import Carousel from "../Carousel";
|
import Carousel from "../Carousel";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
export function GamesSection (data: {
|
||||||
games?: FrontEndGameType[];
|
games?: FrontEndGameType[];
|
||||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||||
|
className?: string;
|
||||||
|
showSources?: boolean;
|
||||||
|
ref?: Ref<any>;
|
||||||
} & FocusParams)
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey, focused, focusSelf } = useFocusable({
|
||||||
focusKey: FOCUS_KEYS.GAME_SECTION,
|
focusKey: FOCUS_KEYS.GAME_SECTION,
|
||||||
trackChildren: true,
|
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);
|
const containerRef = useRef(null);
|
||||||
useDragScroll(containerRef);
|
useDragScroll(containerRef);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (focused)
|
||||||
|
focusSelf();
|
||||||
|
}, [!!data.games]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<section ref={ref} className="px-6 py-3 select-none">
|
<section ref={(r) =>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{
|
||||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
ref.current = r;
|
||||||
<Gamepad2 className="text-accent" />
|
if (data.ref instanceof Function) data.ref(r);
|
||||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
else if (data.ref) data.ref.current = r;
|
||||||
Featured Games
|
}} className={twMerge("select-none", data.className)}>
|
||||||
</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>
|
|
||||||
<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">
|
<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}
|
key={g.id.id}
|
||||||
game={g}
|
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" />)}
|
index={i} />) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full" />)}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</section>
|
</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>
|
</FocusContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
import queries from "@/mainview/scripts/queries";
|
|
||||||
|
import { storeGetStatsQuery } from "@queries/store";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ export function StatsSection ({
|
||||||
}: StatsSectionProps)
|
}: StatsSectionProps)
|
||||||
{
|
{
|
||||||
|
|
||||||
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
|
const { data: stats } = useQuery(storeGetStatsQuery);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="px-6 pt-3 pb-4">
|
<section className="px-6 pt-3 pb-4">
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@ import { Button } from "../options/Button";
|
||||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
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 { 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: {
|
export function StoreEmulatorCard (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -35,7 +45,7 @@ export function StoreEmulatorCard (data: {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-installed={data.emulator.exists ? true : undefined}
|
data-installed={!!data.emulator.validSource}
|
||||||
onClick={isTouch ? handleSelect : undefined}
|
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)}
|
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 gap-2">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div
|
<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`}
|
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>
|
<img draggable={false} src={data.emulator.logo}></img>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<ul className="flex flex-wrap gap-1">
|
||||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||||
{
|
{
|
||||||
|
|
@ -66,10 +76,12 @@ export function StoreEmulatorCard (data: {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
||||||
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
|
{!!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"><HardDrive /></div>
|
<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>}
|
||||||
{<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 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>}
|
</div>}
|
||||||
{isMouse && <>
|
{isMouse && <>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ window.addEventListener('message', (e) =>
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.EJS_threads = true;
|
||||||
window.EJS_player = "#game";
|
window.EJS_player = "#game";
|
||||||
window.EJS_lightgun = false;
|
window.EJS_lightgun = false;
|
||||||
window.EJS_startOnLoaded = true;
|
window.EJS_startOnLoaded = true;
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Store basePath resolved from Vite config
|
// Store basePath resolved from Vite config
|
||||||
const BASE_PATH = "./";
|
const BASE_PATH = "/";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,17 @@ body {
|
||||||
width: 100%;
|
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) {
|
html:active-view-transition-type(zoom-in) {
|
||||||
|
|
||||||
&::view-transition-old(root) {
|
&::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 {
|
@keyframes zoom-in-fade-in {
|
||||||
from {
|
from {
|
||||||
scale: 105%;
|
scale: 105%;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import "./scripts/spatialNavigation";
|
||||||
import NotFound from "./components/NotFound";
|
import NotFound from "./components/NotFound";
|
||||||
import Error from "./components/Error";
|
import Error from "./components/Error";
|
||||||
import serviceWorker from './scripts/serviceWorker?worker&url';
|
import serviceWorker from './scripts/serviceWorker?worker&url';
|
||||||
|
import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
|
||||||
if ('serviceWorker' in navigator)
|
if ('serviceWorker' in navigator)
|
||||||
{
|
{
|
||||||
|
|
@ -44,10 +45,42 @@ export const Router = createRouter({
|
||||||
history: hashHistory,
|
history: hashHistory,
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
context: { queryClient },
|
context: { queryClient },
|
||||||
scrollRestoration: false,
|
scrollRestoration: true,
|
||||||
defaultNotFoundComponent: NotFound,
|
defaultNotFoundComponent: NotFound,
|
||||||
defaultPendingMs: 300,
|
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
|
// Register things for typesafety
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { DefaultRommStaleTime } from '@shared/constants';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||||
import queries from '../scripts/queries';
|
import { getCollectionQuery } from '@queries/romm';
|
||||||
|
|
||||||
export const Route = createFileRoute('/collection/$id')({
|
export const Route = createFileRoute('/collection/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -18,7 +18,7 @@ export const Route = createFileRoute('/collection/$id')({
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { data: collection } = useQuery(queries.romm.getCollectionQuery(Number(id)));
|
const { data: collection } = useQuery(getCollectionQuery(Number(id)));
|
||||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,13 @@ import useActiveControl from '../scripts/gamepads';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
||||||
import { RoundButton } from '../components/RoundButton';
|
import { RoundButton } from '../components/RoundButton';
|
||||||
import queries from '../scripts/queries';
|
import { gameQuery } from '@queries/romm';
|
||||||
|
|
||||||
export const Route = createFileRoute('/embedded/$source/$id')({
|
export const Route = createFileRoute('/embedded/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async (ctx) =>
|
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 };
|
return { data };
|
||||||
},
|
},
|
||||||
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
||||||
|
|
@ -133,7 +133,7 @@ function RouteComponent ()
|
||||||
|
|
||||||
function HandleGoBack ()
|
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 =>
|
useEventListener('message', e =>
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,53 @@
|
||||||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
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 { twMerge } from "tailwind-merge";
|
||||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
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 { HeaderUI } from "../../components/Header";
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
|
import { useFocusEventListener } from "../../scripts/spatialNavigation";
|
||||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||||
import { rommApi } from "../../scripts/clientApi";
|
|
||||||
import toast from "react-hot-toast";
|
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 { Router } from "../..";
|
||||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog";
|
||||||
import Shortcuts from "../../components/Shortcuts";
|
import Shortcuts from "../../components/Shortcuts";
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import queries from "@/mainview/scripts/queries";
|
|
||||||
import Screenshots from "@/mainview/components/Screenshots";
|
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 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")({
|
export const Route = createFileRoute("/game/$source/$id")({
|
||||||
loader: async ({ params, context }) =>
|
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 };
|
return { data };
|
||||||
},
|
},
|
||||||
component: GameDetailsUI,
|
component: GameDetailsUI,
|
||||||
pendingComponent: GameDetailsUIPending,
|
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)
|
function Error (data: ErrorComponentProps)
|
||||||
{
|
{
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||||
|
|
@ -51,7 +67,7 @@ function Error (data: ErrorComponentProps)
|
||||||
<HeaderUI />
|
<HeaderUI />
|
||||||
</div>
|
</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="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>
|
||||||
<div className="bg-base-200">
|
<div className="bg-base-200">
|
||||||
|
|
||||||
|
|
@ -139,35 +155,52 @@ function GameDetailsUIPending ()
|
||||||
</AnimatedBackground>;
|
</AnimatedBackground>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HandleGoBack ()
|
function MoreDetails (data: {})
|
||||||
{
|
{
|
||||||
const { to, search } = PopSource('details');
|
const { data: game } = Route.useLoaderData();
|
||||||
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
|
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({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: 'main-details', onFocus: () =>
|
focusKey: 'main-details',
|
||||||
{
|
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||||
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
|
||||||
},
|
|
||||||
preferredChildFocusKey: "play-btn",
|
preferredChildFocusKey: "play-btn",
|
||||||
saveLastFocusedChild: false
|
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");
|
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;
|
let fileSizeIcon: JSX.Element | undefined;
|
||||||
if (!data.game)
|
if (!game)
|
||||||
{
|
{
|
||||||
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
||||||
} else if (data.game.missing)
|
} else if (game.missing)
|
||||||
{
|
{
|
||||||
fileSizeIcon = <TriangleAlert />;
|
fileSizeIcon = <TriangleAlert />;
|
||||||
} else if (data.game.local)
|
} else if (game.local)
|
||||||
{
|
{
|
||||||
fileSizeIcon = <HardDrive />;
|
fileSizeIcon = <HardDrive />;
|
||||||
} else
|
} else
|
||||||
|
|
@ -186,23 +219,23 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
||||||
</div>
|
</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-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">
|
<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>
|
<Detail icon={<Clock />} >{game?.last_played ? new Date(game.last_played).toDateString() : "Never"}</Detail>
|
||||||
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
|
{!!game && (game.fs_size_bytes !== null || game.missing) &&
|
||||||
<div className={classNames({ "text-error": data.game.missing })}>
|
<div className={classNames({ "text-error": game.missing })}>
|
||||||
<div className="tooltip" data-tip={data.game.path_fs}>
|
<div className="tooltip" data-tip={game.path_fs}>
|
||||||
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
|
<Detail icon={fileSizeIcon} >{game.missing ? 'Missing' : prettyBytes(game.fs_size_bytes!)}</Detail>
|
||||||
</div>
|
</div>
|
||||||
</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={
|
<Detail icon={
|
||||||
<Store />
|
<Store />
|
||||||
} >
|
} >
|
||||||
{data.game?.source ?? data.game?.id.source}
|
{game?.source ?? game?.id.source}
|
||||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
{game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden divider divider-vertical m-0"></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">
|
<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-[30%]"></div>
|
||||||
<div className="skeleton h-4 w-[80%]"></div>
|
<div className="skeleton h-4 w-[80%]"></div>
|
||||||
<div className="skeleton h-4 w-full"></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 className="skeleton h-4 w-[80%]"></div>
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
{!!data.game && <ActionButtons key="actions" game={data.game} />}
|
{!!game && <ActionButtons key="actions" />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</main>;
|
</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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
|
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 gap-2 items-center text-2xl">
|
<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">
|
<div className="flex flex-row items-center gap-1">
|
||||||
<Trophy />
|
<Trophy />
|
||||||
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
|
{`${game.achievements.unlocked}/${game.achievements.total}`}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ActionButton>;
|
</ActionButton>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
function MainActions ()
|
||||||
{
|
{
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
const installMutation = useMutation({
|
const installMut = useMutation(installMutation(source, id));
|
||||||
mutationKey: ['install'],
|
const playMut = useMutation({
|
||||||
mutationFn: async () =>
|
...playMutation, onError (error)
|
||||||
{
|
{
|
||||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
|
toast.error(error.message);
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const ws = useRef<{ send: (data: string) => void; }>(undefined);
|
||||||
const [progress, setProgress] = useState<number | undefined>(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 [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||||
const [commands, setCommands] = useState<CommandEntry[] | 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 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(() =>
|
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;
|
setStatus(e.data.status);
|
||||||
setProgress(stats.progress);
|
setProgress((e.data as any).progress);
|
||||||
setStatus(stats.status);
|
setDetails((e.data as any).details);
|
||||||
setDetails(stats.details);
|
setCommands((e.data as any).commands);
|
||||||
setCommands(stats.commands);
|
|
||||||
setError(stats.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
es.addEventListener('refresh', () =>
|
if (e.data.status === '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)
|
|
||||||
{
|
{
|
||||||
const stats = JSON.parse((e as any).data) as GameInstallProgress;
|
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
|
||||||
toast.error(stats.error);
|
Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true });
|
||||||
setError(stats.error);
|
} 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;
|
sub.close();
|
||||||
if (error)
|
ws.current = undefined;
|
||||||
{
|
|
||||||
toast.error(error);
|
|
||||||
setError(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [data.id]);
|
||||||
return () => es.close();
|
|
||||||
}, [data.game.id]);
|
|
||||||
|
|
||||||
let progressIcon: JSX.Element | undefined = undefined;
|
let progressIcon: JSX.Element | undefined = undefined;
|
||||||
switch (status)
|
switch (status)
|
||||||
|
|
@ -319,29 +335,51 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
case 'download':
|
case 'download':
|
||||||
progressIcon = <Download />;
|
progressIcon = <Download />;
|
||||||
break;
|
break;
|
||||||
|
case 'queued':
|
||||||
|
progressIcon = <Clock />;
|
||||||
|
break;
|
||||||
case 'extract':
|
case 'extract':
|
||||||
progressIcon = <PackageOpen />;
|
progressIcon = <PackageOpen />;
|
||||||
break;
|
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')
|
if (status === 'installed')
|
||||||
{
|
{
|
||||||
mainButton = <ActionButton onAction={() =>
|
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
||||||
{
|
key="primary"
|
||||||
const firstValid = commands?.find(c => c.valid);
|
type='primary'
|
||||||
if (firstValid?.emulator === 'emulatorjs')
|
id="mainAction"
|
||||||
{
|
>
|
||||||
const params = new URLSearchParams(firstValid.command);
|
<Play />
|
||||||
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 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
}} 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)
|
else if (error)
|
||||||
{
|
{
|
||||||
|
|
@ -354,8 +392,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
{
|
{
|
||||||
if (status === 'missing-emulator')
|
if (status === 'missing-emulator')
|
||||||
{
|
{
|
||||||
SaveSource('settings');
|
Router.navigate({ to: '/settings/directories' });
|
||||||
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
id="mainAction">
|
id="mainAction">
|
||||||
|
|
@ -366,12 +403,12 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
{
|
{
|
||||||
mainButton = <ActionButton
|
mainButton = <ActionButton
|
||||||
key={status ?? 'unknown'}
|
key={status ?? 'unknown'}
|
||||||
disabled={installMutation.isPending}
|
disabled={installMut.isPending}
|
||||||
onAction={() =>
|
onAction={() =>
|
||||||
{
|
{
|
||||||
if (status === 'install')
|
if (status === 'install')
|
||||||
{
|
{
|
||||||
installMutation.mutate();
|
installMut.mutate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tooltip={details ?? status}
|
tooltip={details ?? status}
|
||||||
|
|
@ -381,10 +418,41 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
</ActionButton>;
|
</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">
|
return <div className="flex gap-2">
|
||||||
{mainButton}
|
{mainButton}
|
||||||
<div className="divider divider-horizontal m-0"></div>
|
<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 key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
{progressIcon}
|
{progressIcon}
|
||||||
|
|
@ -392,26 +460,34 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||||
</div>
|
</div>
|
||||||
</ActionButton>}
|
</ActionButton>}
|
||||||
|
{installOptionsDialog}
|
||||||
|
{allCommandDialog}
|
||||||
</div>;
|
</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 [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||||
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
...queries.romm.deleteGameMutation,
|
...deleteGameMutation(game.id),
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
{
|
{
|
||||||
location.reload();
|
location.reload();
|
||||||
console.log("Deleted");
|
console.log("Deleted");
|
||||||
|
},
|
||||||
|
onError (error)
|
||||||
|
{
|
||||||
|
toast.error(getErrorMessage(error) ?? "Error While Deleting");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextOptions: DialogEntry[] = [];
|
const contextOptions: DialogEntry[] = [];
|
||||||
if (data.game.local)
|
if (game.local)
|
||||||
{
|
{
|
||||||
contextOptions.push({
|
contextOptions.push({
|
||||||
id: 'delete',
|
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">
|
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}>
|
<FocusContext value={focusKey}>
|
||||||
<MainActions game={data.game} />
|
<MainActions />
|
||||||
<AchievementsInfo game={data.game} />
|
<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 tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
|
||||||
|
|
||||||
</ActionButton >
|
</ActionButton >
|
||||||
<ContextDialog id="settings-context" open={open} close={() =>
|
<ContextDialog sourceFocusKey="settings" id="settings-context" open={open} close={setOpen}>
|
||||||
{
|
|
||||||
setOpen(false);
|
|
||||||
setFocus("settings");
|
|
||||||
}}>
|
|
||||||
<ContextList options={contextOptions} />
|
<ContextList options={contextOptions} />
|
||||||
</ContextDialog>
|
</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>}
|
{!!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 = {
|
const styles = {
|
||||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
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",
|
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",
|
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|
@ -516,45 +596,135 @@ function ActionButton (data: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailsUI ()
|
function Stats ()
|
||||||
{
|
{
|
||||||
const { data } = Route.useLoaderData();
|
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 { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
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 }]);
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||||
const { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
focusSelf();
|
if (focus)
|
||||||
|
{
|
||||||
|
setFocus(focus, { instant: true });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
focusSelf();
|
||||||
|
}
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||||
|
const recommendedEmulators = data.emulators?.filter(e => e.store_exists);
|
||||||
|
|
||||||
|
const { ref: intersct } = useIntersectionObserver({
|
||||||
|
onChange: (isIntersecting, entry) =>
|
||||||
|
{
|
||||||
|
setRecommendedGamesVisible(isIntersecting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
||||||
<div className="z-10">
|
<GameDetailsContext value={{
|
||||||
<FocusContext value={focusKey}>
|
update: () => setUpdate(v => v + 1)
|
||||||
<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">
|
<div className="z-10">
|
||||||
<HeaderUI />
|
<FocusContext value={focusKey}>
|
||||||
</div>
|
<div ref={sentinelRef} className="h-0" />
|
||||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
<HeaderUI />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-200">
|
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||||
<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>
|
<Details mainAreaRef={mainAreaRef} />
|
||||||
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
|
</div>
|
||||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
<MoreDetails />
|
||||||
<Shortcuts shortcuts={shortcuts} />
|
<div className="relative bg-base-300">
|
||||||
</footer>
|
{!!recommendedEmulators && recommendedEmulators.length > 0 && <EmulatorsSection
|
||||||
</div>
|
id={`${data.id.id}-recommended`}
|
||||||
</FocusContext>
|
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||||
</div>
|
<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>
|
</AnimatedBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,6 @@ import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
|
||||||
import { FilterUI } from "../components/Filters";
|
import { FilterUI } from "../components/Filters";
|
||||||
import { AnimatedBackground } from "../components/AnimatedBackground";
|
import { AnimatedBackground } from "../components/AnimatedBackground";
|
||||||
import { GameList } from "../components/GameList";
|
import { GameList } from "../components/GameList";
|
||||||
import { SaveSource } from "../scripts/spatialNavigation";
|
|
||||||
import LoadingCardList from "../components/LoadingCardList";
|
import LoadingCardList from "../components/LoadingCardList";
|
||||||
import { AutoFocus } from "../components/AutoFocus";
|
import { AutoFocus } from "../components/AutoFocus";
|
||||||
import SaveScroll from "../components/SaveScroll";
|
import SaveScroll from "../components/SaveScroll";
|
||||||
|
|
@ -46,7 +45,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils";
|
||||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||||
import { FrontEndId } from "@/shared/constants";
|
import { FrontEndId } from "@/shared/constants";
|
||||||
import Carousel from "../components/Carousel";
|
import Carousel from "../components/Carousel";
|
||||||
import queries from "../scripts/queries";
|
import { closeMutation } from "@queries/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: ConsoleHomeUI,
|
component: ConsoleHomeUI,
|
||||||
|
|
@ -125,20 +124,17 @@ function HomeList (data: {
|
||||||
|
|
||||||
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
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 } });
|
||||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCollectionSelect = (id: string) =>
|
const handleCollectionSelect = (id: string) =>
|
||||||
{
|
{
|
||||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
Router.navigate({ to: `/collection/${id}` });
|
||||||
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlatformSelect = (source: string, id: string) =>
|
const handlePlatformSelect = (source: string, id: string) =>
|
||||||
{
|
{
|
||||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
Router.navigate({ to: `/platform/${source}/${id}` });
|
||||||
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let activeList: JSX.Element;
|
let activeList: JSX.Element;
|
||||||
|
|
@ -224,7 +220,6 @@ function MainMenu ()
|
||||||
focusKey: `main-menu`,
|
focusKey: `main-menu`,
|
||||||
trackChildren: true,
|
trackChildren: true,
|
||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -233,13 +228,13 @@ function MainMenu ()
|
||||||
>
|
>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })}
|
action={() => Router.navigate({ to: "/games" })}
|
||||||
icon={<Gamepad2 />}
|
icon={<Gamepad2 />}
|
||||||
label="Home"
|
label="Home"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
/>
|
/>
|
||||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
<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={<Image />} label="Album" />
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
icon={<Gamepad2 />}
|
icon={<Gamepad2 />}
|
||||||
|
|
@ -248,8 +243,7 @@ function MainMenu ()
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
action={() =>
|
action={() =>
|
||||||
{
|
{
|
||||||
SaveSource('settings');
|
Router.navigate({ to: '/settings/accounts' });
|
||||||
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
|
|
||||||
}}
|
}}
|
||||||
icon={<Settings />}
|
icon={<Settings />}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|
@ -294,7 +288,7 @@ export default function ConsoleHomeUI ()
|
||||||
{
|
{
|
||||||
const { filter } = Route.useSearch();
|
const { filter } = Route.useSearch();
|
||||||
|
|
||||||
const close = useMutation(queries.system.closeMutation);
|
const close = useMutation(closeMutation);
|
||||||
|
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
forceFocus: true,
|
forceFocus: true,
|
||||||
|
|
@ -304,29 +298,7 @@ export default function ConsoleHomeUI ()
|
||||||
preferredChildFocusKey: `home-list`,
|
preferredChildFocusKey: `home-list`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } });
|
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
|
||||||
|
|
||||||
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 { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
const headerButtons = [];
|
const headerButtons = [];
|
||||||
|
|
@ -342,6 +314,7 @@ export default function ConsoleHomeUI ()
|
||||||
</div>
|
</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">
|
<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
|
<FilterUI
|
||||||
|
rootFocusKey={focusKey}
|
||||||
id="home"
|
id="home"
|
||||||
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
|
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 }]))}
|
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||||
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import Shortcuts from '../components/Shortcuts';
|
import Shortcuts from '../components/Shortcuts';
|
||||||
import queries from '../scripts/queries';
|
import { gameQuery } from '@queries/romm';
|
||||||
|
|
||||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -18,12 +18,12 @@ function RouteComponent ()
|
||||||
{
|
{
|
||||||
function HandleGoBack ()
|
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 { source, id } = Route.useParams();
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
|
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 }]);
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||||
const { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { RPC_URL } from "../../shared/constants";
|
||||||
import queries from "../scripts/queries";
|
import { platformQuery } from "@queries/romm";
|
||||||
|
|
||||||
export const Route = createFileRoute("/platform/$source/$id")({
|
export const Route = createFileRoute("/platform/$source/$id")({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -22,7 +22,7 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string;
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
const { data: platform } = useQuery(queries.romm.platformQuery(source, id));
|
const { data: platform } = useQuery(platformQuery(source, id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
import queries from '@/mainview/scripts/queries';
|
|
||||||
|
import { systemInfoQuery } from '@queries/system';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
@ -10,7 +11,7 @@ export const Route = createFileRoute('/settings/about')({
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { data: systemInfo } = useQuery(queries.system.systemInfoQuery);
|
const { data: systemInfo } = useQuery(systemInfoQuery);
|
||||||
return <table className="table">
|
return <table className="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import QRCode from "react-qr-code";
|
||||||
import { useJobStatus } from "@/mainview/scripts/utils";
|
import { useJobStatus } from "@/mainview/scripts/utils";
|
||||||
import { useInterval } from "usehooks-ts";
|
import { useInterval } from "usehooks-ts";
|
||||||
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
|
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")({
|
export const Route = createFileRoute("/settings/accounts")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -52,14 +53,14 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url:
|
||||||
|
|
||||||
function TwitchLogin ()
|
function TwitchLogin ()
|
||||||
{
|
{
|
||||||
const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery);
|
const loginStatus = useQuery(twitchLoginVerificationQuery);
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
...queries.settings.twitchLoginMutation,
|
...twitchLoginMutation,
|
||||||
onSuccess: () => loginStatus.refetch()
|
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() });
|
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
|
||||||
|
|
||||||
|
|
@ -84,13 +85,13 @@ function TwitchLogin ()
|
||||||
|
|
||||||
function LoginControls (data: { hasPassword: boolean; })
|
function LoginControls (data: { hasPassword: boolean; })
|
||||||
{
|
{
|
||||||
const user = useQuery(queries.romm.rommUserQuery());
|
const user = useQuery(rommUserQuery());
|
||||||
const loginMutation = useMutation(queries.romm.rommQrLoginMutation);
|
const loginMutation = useMutation(rommQrLoginMutation);
|
||||||
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
||||||
const context = useSettingsFormContext({});
|
const context = useSettingsFormContext({});
|
||||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
...queries.romm.rommLogoutMutation,
|
...rommLogoutMutation,
|
||||||
onSuccess: async (d, v, r, c) =>
|
onSuccess: async (d, v, r, c) =>
|
||||||
{
|
{
|
||||||
user.refetch();
|
user.refetch();
|
||||||
|
|
@ -136,9 +137,9 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery);
|
const { data: hasPassword } = useQuery(rommHasPasswordQuery);
|
||||||
const { data: hostname } = useQuery(queries.romm.rommHostnameQuery);
|
const { data: hostname } = useQuery(rommHostnameQuery);
|
||||||
const { data: username } = useQuery(queries.romm.rommUsernameQuery);
|
const { data: username } = useQuery(rommUsernameQuery);
|
||||||
|
|
||||||
const loginForm = useSettingsForm({
|
const loginForm = useSettingsForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -160,7 +161,7 @@ function RouteComponent ()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const rommOnline = useQuery(queries.romm.rommGetOptionsQuery());
|
const rommOnline = useQuery(rommGetOptionsQuery());
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -170,7 +171,7 @@ function RouteComponent ()
|
||||||
}
|
}
|
||||||
}, [focus]);
|
}, [focus]);
|
||||||
|
|
||||||
const loginMutation = useMutation(queries.romm.rommLoginMutation);
|
const loginMutation = useMutation(rommLoginMutation);
|
||||||
|
|
||||||
let indicator = "";
|
let indicator = "";
|
||||||
if (rommOnline.isError)
|
if (rommOnline.isError)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
||||||
import { Block, createFileRoute } from '@tanstack/react-router';
|
import { Block, createFileRoute } from '@tanstack/react-router';
|
||||||
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
||||||
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import queries from '@/mainview/scripts/queries';
|
|
||||||
import { DownloadsDrive } from '@/shared/constants';
|
import { DownloadsDrive } from '@/shared/constants';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
@ -13,6 +12,7 @@ import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||||
import { Button } from '@/mainview/components/options/Button';
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
import { systemApi } from '@/mainview/scripts/clientApi';
|
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||||
import useActiveControl from '@/mainview/scripts/gamepads';
|
import useActiveControl from '@/mainview/scripts/gamepads';
|
||||||
|
import { changeDownloadsMutation } from '@queries/settings';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/directories')({
|
export const Route = createFileRoute('/settings/directories')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
|
||||||
focusKey: data.drive.device,
|
focusKey: data.drive.device,
|
||||||
onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
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 usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
|
||||||
const usedPercent = usedWithoutDownlods / data.drive.size;
|
const usedPercent = usedWithoutDownlods / data.drive.size;
|
||||||
const usedPercentRaw = data.drive.used / 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 shortcuts: Shortcut[] = [];
|
||||||
const valid = !data.drive.unusableReason && isMoving <= 0;
|
const valid = !data.drive.unusableReason && isMoving <= 0;
|
||||||
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);
|
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
|
||||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
import FilePicker from '@/mainview/components/FilePicker';
|
import FilePicker from '@/mainview/components/FilePicker';
|
||||||
import { dirname } from 'pathe';
|
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')({
|
export const Route = createFileRoute('/settings/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -98,13 +98,13 @@ function EmulatorPath (data: { id: string; })
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id));
|
const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id));
|
||||||
const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) =>
|
const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) =>
|
||||||
{
|
{
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
}));
|
}));
|
||||||
const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id));
|
const deleteMutation = useMutation(customEmulatorDeleteMutation(data.id));
|
||||||
|
|
||||||
const handleSave = useCallback(() =>
|
const handleSave = useCallback(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -223,11 +223,11 @@ function EmulatorBadge (data: {
|
||||||
|
|
||||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
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 });
|
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'>
|
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
||||||
<FocusContext value={focusKey}>
|
<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>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -240,9 +240,9 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
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}>
|
return <FocusContext value={focusKey}>
|
||||||
<ul ref={ref} className="list rounded-box gap-2">
|
<ul ref={ref} className="list rounded-box gap-2">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import
|
||||||
Outlet,
|
Outlet,
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
useMatch,
|
useMatch,
|
||||||
useNavigate,
|
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
@ -25,10 +24,10 @@ import { JSX, useEffect } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SettingsSchema } from "../../../shared/constants";
|
import { SettingsSchema } from "../../../shared/constants";
|
||||||
import { PopSource } from "../../scripts/spatialNavigation";
|
|
||||||
import { Router } from "../..";
|
import { Router } from "../..";
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||||
|
import { HandleGoBack } from "@/mainview/scripts/utils";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings")({
|
export const Route = createFileRoute("/settings")({
|
||||||
component: SettingsUI,
|
component: SettingsUI,
|
||||||
|
|
@ -48,21 +47,26 @@ function MenuItem (data: {
|
||||||
label: string;
|
label: string;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
|
||||||
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
||||||
const handleNonFocusSelect = () =>
|
const handleNonFocusSelect = () =>
|
||||||
{
|
{
|
||||||
const { to, search } = PopSource('settings');
|
if (data.return)
|
||||||
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
|
{
|
||||||
|
HandleGoBack();
|
||||||
|
} else if (!acitve)
|
||||||
|
{
|
||||||
|
Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
const { ref, focusSelf } = useFocusable({
|
const { ref, focusSelf } = useFocusable({
|
||||||
focusKey: `menu-item-${data.route}`,
|
focusKey: `menu-item-${data.route}`,
|
||||||
forceFocus: !!acitve,
|
forceFocus: !!acitve,
|
||||||
onFocus: () =>
|
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' });
|
(ref.current as HTMLElement).scrollIntoView({ inline: 'center' });
|
||||||
},
|
},
|
||||||
|
|
@ -104,12 +108,13 @@ function SettingsMenu (data: {})
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusable: true,
|
focusable: true,
|
||||||
focusKey: 'settings-menu',
|
focusKey: 'settings-menu',
|
||||||
preferredChildFocusKey: location.hash.replace("#", '')
|
preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '')
|
||||||
});
|
});
|
||||||
|
|
||||||
return <ul
|
return <ul
|
||||||
ref={ref}
|
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"
|
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}>
|
<FocusContext value={focusKey}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -147,25 +152,12 @@ function SettingsMenu (data: {})
|
||||||
route={"/"}
|
route={"/"}
|
||||||
return
|
return
|
||||||
label="Return"
|
label="Return"
|
||||||
viewTransition={{ types: ['zoom-out'] }}
|
|
||||||
icon={<ArrowBigLeft />}
|
icon={<ArrowBigLeft />}
|
||||||
/>
|
/>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</ul>;
|
</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 ()
|
export function SettingsUI ()
|
||||||
{
|
{
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
|
|
|
||||||
|
|
@ -1,174 +1,264 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
useFocusable,
|
useFocusable,
|
||||||
FocusContext,
|
FocusContext,
|
||||||
setFocus,
|
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import { Router } from "@/mainview";
|
import { Router } from "@/mainview";
|
||||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||||
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
||||||
import { PopSource } from "@/mainview/scripts/spatialNavigation";
|
|
||||||
import { systemApi } from "@/mainview/scripts/clientApi";
|
import { systemApi } from "@/mainview/scripts/clientApi";
|
||||||
import queries from "@/mainview/scripts/queries";
|
|
||||||
import { Button } from "@/mainview/components/options/Button";
|
import { Button } from "@/mainview/components/options/Button";
|
||||||
import { ChevronDown, Download, Info, Settings } from "lucide-react";
|
import { ChevronDown, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert } from "lucide-react";
|
||||||
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
|
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
|
||||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
import { FrontEndEmulatorDetailed, RPC_URL } from "@/shared/constants";
|
||||||
import Screenshots from "@/mainview/components/Screenshots";
|
import Screenshots from "@/mainview/components/Screenshots";
|
||||||
import { HeaderUI } from "@/mainview/components/Header";
|
import { StickyHeaderUI } from "@/mainview/components/Header";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
|
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')({
|
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
async loader (ctx)
|
async loader (ctx)
|
||||||
{
|
{
|
||||||
const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id));
|
ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
|
||||||
return { emulator };
|
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' });
|
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 queryClient = useQueryClient();
|
||||||
const installOptions: DialogEntry[] = [];
|
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({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: 'title-area',
|
focusKey: 'title-area',
|
||||||
preferredChildFocusKey: "install-btn",
|
preferredChildFocusKey: "install-btn",
|
||||||
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
|
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}>
|
<FocusContext value={focusKey}>
|
||||||
<img className="size-32" src={data.emulator.logo}></img>
|
{data.emulator ? <img className="size-32" src={data.emulator.logo}></img> : <div className="skeleton h-32 w-32" />}
|
||||||
<div className="flex flex-col grow justify-start gap-1">
|
<div className="flex flex-col grow gap-1 sm:portrait:items-center md:items-start">
|
||||||
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
|
<h1 className="text-4xl font-semibold">{data.emulator?.name ?? <div className="skeleton h-10 w-84" />}</h1>
|
||||||
<p className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
{data.emulator?.systems.map(({ id, name, icon }) =>
|
||||||
{
|
{
|
||||||
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
|
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}`} />}
|
{!!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>
|
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
|
||||||
</p>
|
</div>
|
||||||
<div className="flex pt-2 gap-1">
|
<div className="flex pt-2 gap-1">
|
||||||
<HomePageLink homepage={data.emulator.homepage} />
|
<HomePageLink homepage={data.emulator?.homepage} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
|
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4">
|
||||||
data.emulator.exists ?
|
<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} >
|
||||||
<><Settings /> Options</> :
|
<div className="flex gap-4">
|
||||||
<><Download />Install</>
|
{installButtonContent}
|
||||||
}
|
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
||||||
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
<ChevronDown />
|
||||||
<ChevronDown />
|
</div>
|
||||||
</Button>
|
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
|
||||||
|
</Button>
|
||||||
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
|
</div>
|
||||||
{
|
{installOptionsDialog}
|
||||||
setInstallOpen(false);
|
</FocusContext >
|
||||||
setFocus("install-btn");
|
</div >;
|
||||||
}}>
|
|
||||||
<ContextList options={installOptions}>
|
|
||||||
|
|
||||||
</ContextList>
|
|
||||||
</ContextDialog>
|
|
||||||
</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">
|
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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteComponent ()
|
export function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const headerRef = useRef(null);
|
|
||||||
const sentinelRef = useRef(null);
|
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
focusKey: `GAME_DETAIL_${id}`,
|
focusKey: `GAME_DETAIL_${id}`,
|
||||||
trackChildren: true,
|
trackChildren: true,
|
||||||
preferredChildFocusKey: 'title-area'
|
preferredChildFocusKey: 'title-area'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { emulator } = Route.useLoaderData();
|
const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id));
|
||||||
const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
|
||||||
|
const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id));
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{
|
useShortcuts(focusKey, () => [{
|
||||||
label: "Return",
|
label: "Return",
|
||||||
action: () =>
|
action: HandleGoBack,
|
||||||
{
|
|
||||||
const { to, search } = PopSource('store-details');
|
|
||||||
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
|
|
||||||
},
|
|
||||||
button: GamePadButtonCode.B
|
button: GamePadButtonCode.B
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
const installMutation = useMutation({
|
||||||
|
...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
focusSelf();
|
focusSelf();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { shortcuts } = useShortcutContext();
|
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 (
|
return (
|
||||||
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
|
<AnimatedBackground ref={ref} className="" scrolling>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<div className="flex flex-col min-h-full z-10">
|
<StickyHeaderUI ref={ref} />
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<div className="flex flex-col z-10">
|
||||||
<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'>
|
<div className="w-full sm:px-8 md:px-16 pb-8 pt-12">
|
||||||
<HeaderUI />
|
<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>
|
||||||
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
|
<div className="flex flex-col bg-base-100 gap-4 pt-4 h-[50vh] min-h-128 grow text-lg">
|
||||||
<TitleArea emulator={emulator} />
|
{isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && <Screenshots className="grow bg-base-200" screenshots={emulator?.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />}
|
||||||
</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' })} />
|
|
||||||
<Description emulator={emulator} />
|
<Description emulator={emulator} />
|
||||||
</div>
|
</div>
|
||||||
<div className='mobile:hidden bg-gradient'></div>
|
|
||||||
<div className='mobile:hidden bg-noise'></div>
|
|
||||||
</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>
|
<div className="divider"> <Info className="size-12" /> Stats</div>
|
||||||
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
|
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
|
||||||
{!!emulator.keywords &&
|
{recommendedEmulators && <div className="relative bg-base-200">
|
||||||
<li className="flex flex-wrap gap-2 items-center">
|
<EmulatorsSection
|
||||||
<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
|
|
||||||
id={`${id}-recommended`}
|
id={`${id}-recommended`}
|
||||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||||
<h2 className="font-bold uppercase tracking-widest">
|
<h2 className="font-bold uppercase tracking-widest">
|
||||||
|
|
@ -177,11 +267,26 @@ export function RouteComponent ()
|
||||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||||
onSelect={(id, focus) =>
|
onSelect={(id, focus) =>
|
||||||
{
|
{
|
||||||
setFocus("title-area");
|
Router.navigate({
|
||||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
to: '/store/details/emulator/$id', params: { id }
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
emulators={recommended} />}
|
emulators={recommendedEmulators} />
|
||||||
</div>
|
</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>
|
||||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
|
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
|
||||||
<Shortcuts shortcuts={shortcuts} />
|
<Shortcuts shortcuts={shortcuts} />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import queries from '@/mainview/scripts/queries';
|
import { storeEmulatorsQuery } from '@queries/store';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/emulators')({
|
export const Route = createFileRoute('/store/tab/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -22,7 +22,7 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
const storeContext = useContext(StoreContext);
|
const storeContext = useContext(StoreContext);
|
||||||
const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery);
|
const { data: emulators } = useQuery(storeEmulatorsQuery);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||||
import queries from '@/mainview/scripts/queries';
|
import { storeGamesInfiniteQuery } from '@queries/store';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/games')({
|
export const Route = createFileRoute('/store/tab/games')({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -17,7 +17,7 @@ function RouteComponent ()
|
||||||
const { focus } = useSearch({ from: '/store/tab' });
|
const { focus } = useSearch({ from: '/store/tab' });
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -52,7 +52,7 @@ function RouteComponent ()
|
||||||
<div className="skeleton h-4 w-[40%]"></div>
|
<div className="skeleton h-4 w-[40%]"></div>
|
||||||
</div>)}
|
</div>)}
|
||||||
<LoadMoreButton
|
<LoadMoreButton
|
||||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
|
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
isFetching={isFetchingNextPage || isFetching}
|
isFetching={isFetchingNextPage || isFetching}
|
||||||
onAction={() =>
|
onAction={() =>
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,16 @@ import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
||||||
import { GamesSection } from "../../../components/store/GamesSection";
|
import { GamesSection } from "../../../components/store/GamesSection";
|
||||||
import { StatsSection } from "../../../components/store/StatsSection";
|
import { StatsSection } from "../../../components/store/StatsSection";
|
||||||
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
||||||
import queries from '@/mainview/scripts/queries';
|
|
||||||
import { useContext, useEffect, useRef, useState } from 'react';
|
import { useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import { useInterval } from 'usehooks-ts';
|
import { useInterval } from 'usehooks-ts';
|
||||||
import { Button } from '@/mainview/components/options/Button';
|
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 { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { autoEmulatorsQuery } from '@queries/settings';
|
||||||
|
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/')({
|
export const Route = createFileRoute('/store/tab/')({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -106,9 +107,9 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
export function RouteComponent ()
|
export function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { focus } = useSearch({ from: '/store/tab' });
|
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: crucialEmulators, isSuccess } = useQuery({ ...autoEmulatorsQuery, select: (data) => data.filter(e => !e.validSource && e.isCritical) });
|
||||||
const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery);
|
const { data: featuredGames } = useQuery(storeFeaturedGamesQuery);
|
||||||
const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
|
||||||
|
|
||||||
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
|
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
|
||||||
const storeContext = useContext(StoreContext);
|
const storeContext = useContext(StoreContext);
|
||||||
|
|
@ -137,11 +138,22 @@ export function RouteComponent ()
|
||||||
emulators={recommendedEmulators} />
|
emulators={recommendedEmulators} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GamesSection
|
<div className="px-6 py-3">
|
||||||
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
|
<div className="flex items-center gap-3 mb-4">
|
||||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||||
games={featuredGames}
|
<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
|
<StatsSection
|
||||||
romCount={1240}
|
romCount={1240}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { HeaderUI } from '@/mainview/components/Header';
|
||||||
import Shortcuts from '@/mainview/components/Shortcuts';
|
import Shortcuts from '@/mainview/components/Shortcuts';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
|
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 { 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 { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
|
|
@ -33,29 +33,51 @@ function TopArea (data: { filters: Record<string, FilterOption>; })
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: 'top-area',
|
focusKey: 'top-area',
|
||||||
preferredChildFocusKey: 'store-tabs',
|
preferredChildFocusKey: `store-tabs`,
|
||||||
onFocus: () =>
|
onFocus: () =>
|
||||||
{
|
{
|
||||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
|
(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}>
|
return <div ref={ref}>
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div className='w-full'>
|
<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>
|
</div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StoreOutlet ()
|
||||||
|
{
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: "STORE_OUTLET" });
|
||||||
|
return <div ref={ref}>
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
<Outlet />
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
// Root spatial nav container
|
// Root spatial nav container
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
focusKey: "STORE_ROOT",
|
focusKey: "STORE_ROOT",
|
||||||
trackChildren: true,
|
preferredChildFocusKey: 'top-area',
|
||||||
preferredChildFocusKey: 'top-area'
|
forceFocus: true
|
||||||
});
|
});
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
|
@ -65,34 +87,6 @@ function RouteComponent ()
|
||||||
games: { label: "Games", selected: useIsSettings('games') }
|
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 { shortcuts } = useShortcutContext();
|
||||||
const { focus } = Route.useSearch();
|
const { focus } = Route.useSearch();
|
||||||
|
|
||||||
|
|
@ -102,31 +96,24 @@ function RouteComponent ()
|
||||||
{
|
{
|
||||||
focusSelf();
|
focusSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
||||||
{
|
{
|
||||||
|
|
||||||
if (type === 'emulator')
|
if (type === 'emulator')
|
||||||
{
|
{
|
||||||
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
|
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
|
||||||
}
|
}
|
||||||
else if (type === 'game')
|
else if (type === 'game')
|
||||||
{
|
{
|
||||||
console.log(source, id);
|
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: 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'] } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const match = Route.useMatch();
|
|
||||||
const goToSettings = () =>
|
const goToSettings = () =>
|
||||||
{
|
{
|
||||||
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
|
Router.navigate({ to: '/settings' });
|
||||||
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = mobileCheck();
|
const isMobile = mobileCheck();
|
||||||
|
|
@ -141,7 +128,7 @@ function RouteComponent ()
|
||||||
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
||||||
</div>
|
</div>
|
||||||
<TopArea filters={filters} />
|
<TopArea filters={filters} />
|
||||||
<Outlet />
|
<StoreOutlet />
|
||||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
|
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
|
||||||
<Shortcuts shortcuts={shortcuts} />
|
<Shortcuts shortcuts={shortcuts} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title>Twitch</title>
|
<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" />
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||||
</svg>;
|
</svg>;
|
||||||
|
|
||||||
|
export const 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>;
|
||||||
|
|
@ -31,4 +31,8 @@ export const FilePickerContext = createContext<{
|
||||||
refetchFiles: () => void;
|
refetchFiles: () => void;
|
||||||
drives: Drive[],
|
drives: Drive[],
|
||||||
activeDrive: Drive | undefined;
|
activeDrive: Drive | undefined;
|
||||||
|
}>({} as any);
|
||||||
|
|
||||||
|
export const GameDetailsContext = createContext<{
|
||||||
|
update: () => void;
|
||||||
}>({} as any);
|
}>({} as any);
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
|
|
@ -4,76 +4,116 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||||
|
|
||||||
export default {
|
export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({
|
||||||
allGamesQuery: (filter?: GameListFilterType) => queryOptions({
|
queryKey: ['games', filter ?? 'all'],
|
||||||
queryKey: ['games', filter ?? 'all'],
|
queryFn: async () =>
|
||||||
queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await rommApi.api.romm.games.get({ query: filter });
|
||||||
const { data, error } = await rommApi.api.romm.games.get({ query: filter });
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
}
|
||||||
}
|
});
|
||||||
}),
|
export const gameQuery = (source: string, id: string) => queryOptions({
|
||||||
gameQuery: (source: string, id: string) => queryOptions({
|
queryKey: ['game', source, id],
|
||||||
queryKey: ['game', source, id],
|
queryFn: async () =>
|
||||||
queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
||||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
},
|
||||||
},
|
});
|
||||||
}),
|
export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() });
|
||||||
rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }),
|
export const rommQrLoginMutation = mutationOptions({
|
||||||
rommQrLoginMutation: mutationOptions({
|
mutationKey: ['login', 'qr', 'cancel'],
|
||||||
mutationKey: ['login', 'qr', 'cancel'],
|
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
});
|
||||||
}),
|
export const rommLoginMutation = mutationOptions({
|
||||||
rommLoginMutation: mutationOptions({
|
mutationKey: ["romm", "login"],
|
||||||
mutationKey: ["romm", "login"],
|
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
|
||||||
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
|
{
|
||||||
{
|
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||||
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
if (error) throw error;
|
||||||
if (error) throw error;
|
},
|
||||||
},
|
onSuccess: (d, v, r, c) =>
|
||||||
onSuccess: (d, v, r, c) =>
|
{
|
||||||
{
|
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
|
||||||
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
|
},
|
||||||
},
|
onError: (e) =>
|
||||||
onError: (e) =>
|
{
|
||||||
{
|
console.error(e);
|
||||||
console.error(e);
|
},
|
||||||
},
|
});
|
||||||
}),
|
export const rommUserQuery = () => queryOptions({
|
||||||
rommUserQuery: () => queryOptions({
|
...getCurrentUserApiUsersMeGetOptions(),
|
||||||
...getCurrentUserApiUsersMeGetOptions(),
|
queryKey: ['romm', 'auth', "login"],
|
||||||
queryKey: ['romm', 'auth', "login"],
|
refetchOnWindowFocus: false,
|
||||||
refetchOnWindowFocus: false,
|
retry: 0
|
||||||
retry: 0
|
});
|
||||||
}),
|
export const rommGetOptionsQuery = () => queryOptions({
|
||||||
rommGetOptionsQuery: () => queryOptions({
|
...statsApiStatsGetOptions(),
|
||||||
...statsApiStatsGetOptions(),
|
refetchInterval: 30000,
|
||||||
refetchInterval: 30000,
|
retry: false,
|
||||||
retry: false,
|
});
|
||||||
}),
|
export const rommHasPasswordQuery = queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||||
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) });
|
||||||
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) });
|
||||||
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({
|
||||||
deleteGameMutation: (id: FrontEndId) => mutationOptions({
|
mutationKey: ['delete', id],
|
||||||
mutationKey: ['delete', id],
|
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
||||||
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
});
|
||||||
}),
|
export const getCollectionsQuery = () => queryOptions({
|
||||||
getCollectionsQuery: () => queryOptions({
|
...getCollectionsApiCollectionsGetOptions(),
|
||||||
...getCollectionsApiCollectionsGetOptions(),
|
refetchOnWindowFocus: false,
|
||||||
refetchOnWindowFocus: false,
|
staleTime: DefaultRommStaleTime
|
||||||
staleTime: DefaultRommStaleTime
|
});
|
||||||
}),
|
export const getCollectionQuery = (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) });
|
||||||
getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }),
|
export const platformQuery = (source: string, id: string) => queryOptions({
|
||||||
platformQuery: (source: string, id: string) => queryOptions({
|
queryKey: ['platform', source, id], queryFn: async () =>
|
||||||
queryKey: ['platform', source, id], queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
}, staleTime: DefaultRommStaleTime
|
||||||
}, 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -3,132 +3,130 @@ import { getErrorMessage } from "react-error-boundary";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { rommApi, settingsApi } from "../clientApi";
|
import { rommApi, settingsApi } from "../clientApi";
|
||||||
|
|
||||||
export default {
|
export const changeDownloadsMutation = mutationOptions({
|
||||||
changeDownloadsMutation: mutationOptions({
|
mutationKey: ["setting", "downloads"],
|
||||||
mutationKey: ["setting", "downloads"],
|
mutationFn: async (value: any) =>
|
||||||
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;
|
||||||
if (d.error) throw d.error;
|
}), {
|
||||||
return d.data;
|
success: e => `Download Moved to ${e}`,
|
||||||
}), {
|
loading: "Moving Download",
|
||||||
success: e => `Download Moved to ${e}`,
|
error: e => getErrorMessage(e) ?? "Error Moving Download"
|
||||||
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;
|
||||||
}
|
}
|
||||||
}),
|
return failureCount < 3;
|
||||||
autoEmulatorsQuery: queryOptions({
|
},
|
||||||
queryKey: ['auto-emulators'], queryFn: async () =>
|
queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
|
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
||||||
if (error) throw error;
|
if (error) throw { ...error, status };
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
twitchLogoutMutation: mutationOptions({
|
export const customEmulatorsQuery = queryOptions({
|
||||||
mutationKey: ['twitch', 'logout'],
|
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||||
mutationFn: () =>
|
{
|
||||||
{
|
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||||
return rommApi.api.romm.logout.twitch.post();
|
if (error) throw error;
|
||||||
}
|
return data;
|
||||||
}),
|
}
|
||||||
twitchLoginMutation: mutationOptions({
|
});
|
||||||
mutationKey: ['twitch', 'login'],
|
export const customEmulatorAddMutation = mutationOptions({
|
||||||
mutationFn: (openInBrowser: boolean) =>
|
mutationKey: ['emulator', 'custom', 'add'],
|
||||||
{
|
mutationFn: async (id: string) =>
|
||||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
{
|
||||||
}
|
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||||
}),
|
if (error) throw error;
|
||||||
twitchLoginVerificationQuery: queryOptions({
|
return data;
|
||||||
queryKey: ['twitch', 'login', 'status'],
|
},
|
||||||
retry (failureCount, error)
|
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||||
{
|
});
|
||||||
if ((error as any).status === 404)
|
export const customEmulatorDeleteMutation = (id: string) => mutationOptions({
|
||||||
{
|
mutationKey: ["emulator", id, 'delete'],
|
||||||
return false;
|
mutationFn: async () =>
|
||||||
}
|
{
|
||||||
return failureCount < 3;
|
const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete();
|
||||||
},
|
if (error) throw error;
|
||||||
queryFn: async () =>
|
},
|
||||||
{
|
onSuccess: (d, v, r, ctx) =>
|
||||||
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
{
|
||||||
if (error) throw { ...error, status };
|
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||||
return data;
|
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
customEmulatorsQuery: queryOptions({
|
export const setCustomEmulatorMutation = (id: string, onSuccess?: (value: string) => void) => mutationOptions({
|
||||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
mutationKey: ["emulator", id, 'set'],
|
||||||
{
|
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
|
||||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
onSuccess: (d, v, r, ctx) =>
|
||||||
if (error) throw error;
|
{
|
||||||
return data;
|
ctx.client.invalidateQueries({ queryKey: ["emulator", id] });
|
||||||
}
|
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||||
}),
|
onSuccess?.(v);
|
||||||
customEmulatorAddMutation: mutationOptions({
|
}
|
||||||
mutationKey: ['emulator', 'custom', 'add'],
|
});
|
||||||
mutationFn: async (id: string) =>
|
export const customEmulatorRemoveValueQuery = (id?: string) => queryOptions({
|
||||||
{
|
enabled: !!id,
|
||||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
queryKey: ["emulator", id],
|
||||||
if (error) throw error;
|
queryFn: async () =>
|
||||||
return data;
|
{
|
||||||
},
|
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get();
|
||||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
if (error) throw error;
|
||||||
}),
|
return value;
|
||||||
customEmulatorDeleteMutation: (id: string) => mutationOptions({
|
},
|
||||||
mutationKey: ["emulator", id, 'delete'],
|
});
|
||||||
mutationFn: async () =>
|
export const setSettingMutation = (id?: string) => mutationOptions({
|
||||||
{
|
mutationKey: ["setting", id],
|
||||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete();
|
mutationFn: async (value: any) =>
|
||||||
if (error) throw error;
|
{
|
||||||
},
|
const response = await settingsApi.api.settings({ id: id! }).post({ value });
|
||||||
onSuccess: (d, v, r, ctx) =>
|
if (response.error) throw response.error;
|
||||||
{
|
return response.data;
|
||||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
}
|
||||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
});
|
||||||
}
|
export const getSettingQuery = (id: string | undefined) => queryOptions({
|
||||||
}),
|
enabled: !!id,
|
||||||
setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({
|
queryKey: ["setting", id],
|
||||||
mutationKey: ["emulator", id, 'set'],
|
queryFn: async () =>
|
||||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
|
{
|
||||||
onSuccess: (d, v, r, ctx) =>
|
const { data: value, error } = await settingsApi.api.settings({ id: id! }).get();
|
||||||
{
|
if (error) throw error;
|
||||||
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 value.value;
|
return value.value;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
};
|
|
||||||
|
|
@ -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 { rommApi, storeApi } from "../clientApi";
|
||||||
import { FrontEndGameType } from "@/shared/constants";
|
import { FrontEndGameType } from "@/shared/constants";
|
||||||
|
|
||||||
export default {
|
|
||||||
storeEmulatorsQuery: queryOptions({
|
export const storeEmulatorsQuery = queryOptions({
|
||||||
queryKey: ['store-emulators'], queryFn: async () =>
|
queryKey: ['store-emulators'], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await storeApi.api.store.emulators.get();
|
const { data, error } = await storeApi.api.store.emulators.get();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
storeFeaturedGamesQuery: queryOptions({
|
export const storeFeaturedGamesQuery = queryOptions({
|
||||||
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
|
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await storeApi.api.store.games.featured.get();
|
const { data, error } = await storeApi.api.store.games.featured.get();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
storeEmulatorsRecommendedQuery: queryOptions({
|
export const storeEmulatorsRecommendedQuery = queryOptions({
|
||||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
storeEmulatorDetailsQuery: (id: string) => queryOptions({
|
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
|
||||||
queryKey: ['store-emulator', id], queryFn: async () =>
|
queryKey: ['store-emulator', id], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
|
const { data, error } = await storeApi.api.store.emulator({ id }).get();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
export const storeEmulatorDeleteMutation = mutationOptions({
|
||||||
initialPageParam: 0,
|
mutationKey: ['store-emulator', 'delete'],
|
||||||
queryKey: ['store-games'],
|
mutationFn: async (id: string) =>
|
||||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
{
|
||||||
queryFn: async (data) =>
|
const { error } = await storeApi.api.store.emulator({ id }).delete();
|
||||||
{
|
if (error) throw error;
|
||||||
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;
|
export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||||
return { data: games.games, nextPage: pageParam + 1 };
|
initialPageParam: 0,
|
||||||
}
|
queryKey: ['store-games'],
|
||||||
}),
|
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||||
storeGetStatsQuery: queryOptions({
|
queryFn: async (data) =>
|
||||||
queryKey: ['store', 'stats'], queryFn: async () =>
|
{
|
||||||
{
|
const pageParam = data.pageParam as number;
|
||||||
const { data, error } = await storeApi.api.store.stats.get();
|
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,51 +1,49 @@
|
||||||
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
import { systemApi } from "../clientApi";
|
import { systemApi } from "../clientApi";
|
||||||
|
|
||||||
export default {
|
export const drivesQuery = queryOptions({
|
||||||
drivesQuery: queryOptions({
|
queryKey: ['drives'],
|
||||||
queryKey: ['drives'],
|
queryFn: async () =>
|
||||||
queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await systemApi.api.system.drives.get();
|
||||||
const { data, error } = await systemApi.api.system.drives.get();
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
}
|
||||||
}
|
});
|
||||||
}),
|
export const downloadDrivesQuery = queryOptions({
|
||||||
downloadDrivesQuery: queryOptions({
|
queryKey: ['drives', 'download'],
|
||||||
queryKey: ['drives', 'download'],
|
queryFn: async () =>
|
||||||
queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await systemApi.api.system.drives.download.get();
|
||||||
const { data, error } = await systemApi.api.system.drives.download.get();
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
}
|
||||||
}
|
});
|
||||||
}),
|
export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({
|
||||||
filesQuery: (currentPath: string | undefined, id: string) => queryOptions({
|
queryKey: ['files', currentPath ?? '', id],
|
||||||
queryKey: ['files', currentPath ?? '', id],
|
queryFn: async () =>
|
||||||
queryFn: async () =>
|
{
|
||||||
{
|
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
|
||||||
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
|
if (error) throw error;
|
||||||
if (error) throw error;
|
return data;
|
||||||
return data;
|
},
|
||||||
},
|
placeholderData: keepPreviousData
|
||||||
placeholderData: keepPreviousData
|
});
|
||||||
}),
|
export const systemInfoQuery = queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||||
systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }),
|
export const createFolderMutation = (id: string) => mutationOptions({
|
||||||
createFolderMutation: (id: string) => mutationOptions({
|
|
||||||
|
|
||||||
mutationKey: ['create', 'folder', id],
|
mutationKey: ['create', 'folder', id],
|
||||||
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
|
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
|
||||||
{
|
{
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
|
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
|
||||||
if (error) throw error.value;
|
if (error) throw error.value;
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
closeMutation: mutationOptions({
|
export const closeMutation = mutationOptions({
|
||||||
mutationKey: ['close'], mutationFn: async () =>
|
mutationKey: ['close'], mutationFn: async () =>
|
||||||
{
|
{
|
||||||
const { error } = await systemApi.api.system.exit.post();
|
const { error } = await systemApi.api.system.exit.post();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
};
|
|
||||||
|
|
@ -9,8 +9,6 @@ import
|
||||||
UseFocusableResult,
|
UseFocusableResult,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { RefObject, useEffect, useState } from "react";
|
import { RefObject, useEffect, useState } from "react";
|
||||||
import { Router } from "..";
|
|
||||||
import { RouteIds } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
init({
|
init({
|
||||||
shouldFocusDOMNode: false,
|
shouldFocusDOMNode: false,
|
||||||
|
|
@ -22,43 +20,10 @@ let updateFocusable = SpatialNavigation.updateFocusable.bind(SpatialNavigation);
|
||||||
let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation);
|
let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation);
|
||||||
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
||||||
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
|
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
|
||||||
|
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
|
||||||
|
|
||||||
type SaveFocusType = "session" | "local";
|
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)
|
export function GetFocusedElement (focusKey: string)
|
||||||
{
|
{
|
||||||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement | undefined;
|
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 }));
|
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) =>
|
SpatialNavigation.updateFocusable = (key, data) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { FrontEndId } from "@/shared/constants";
|
||||||
|
|
||||||
export const FOCUS_KEYS = {
|
export const FOCUS_KEYS = {
|
||||||
NAV_CATEGORIES: "NAV_CATEGORIES",
|
NAV_CATEGORIES: "NAV_CATEGORIES",
|
||||||
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
||||||
|
|
@ -6,6 +8,6 @@ export const FOCUS_KEYS = {
|
||||||
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
||||||
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
||||||
GAME_SECTION: "GAME_SECTION",
|
GAME_SECTION: "GAME_SECTION",
|
||||||
GAME_CARD: (id: string) => `GAME_${id}`,
|
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
|
||||||
STATS_SECTION: "STATS_SECTION",
|
STATS_SECTION: "STATS_SECTION",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
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 { RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
import { jobsApi } from "./clientApi";
|
import { jobsApi } from "./clientApi";
|
||||||
import { JobsAPIType } from "@/bun/api/rpc";
|
import { JobsAPIType } from "@/bun/api/rpc";
|
||||||
|
import { Router } from "..";
|
||||||
|
import data from "@emulators";
|
||||||
|
|
||||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
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)
|
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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -239,6 +245,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
|
||||||
([entry]) =>
|
([entry]) =>
|
||||||
{
|
{
|
||||||
el.toggleAttribute("data-stuck", !entry.isIntersecting);
|
el.toggleAttribute("data-stuck", !entry.isIntersecting);
|
||||||
|
callback?.(!entry.isIntersecting);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: scrollRef.current ?? null,
|
root: scrollRef.current ?? null,
|
||||||
|
|
@ -249,7 +256,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
|
||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [scrollRef.current]);
|
}, [scrollRef.current, callback]);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtractField<T, TYPE, K extends string> =
|
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']> (
|
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
|
||||||
id: JOB,
|
id: JOB,
|
||||||
init?: {
|
init?: {
|
||||||
onProgress?: (process: number) => void,
|
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress", 'data'>) => void,
|
||||||
onEnded?: () => void;
|
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
type Response = JobResponse<JOB>;
|
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 ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
|
||||||
const [data, setData] = useState<DataPayload>();
|
const [data, setData] = useState<DataPayload>();
|
||||||
const [status, setStatus] = useState<string>();
|
const [status, setStatus] = useState<string>();
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -287,9 +295,13 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
setStatus(status);
|
setStatus(status);
|
||||||
setData(undefined);
|
setData(undefined);
|
||||||
|
init?.onError?.(data.error);
|
||||||
break;
|
break;
|
||||||
case 'ended':
|
case 'ended':
|
||||||
init?.onEnded?.();
|
setStatus(status);
|
||||||
|
setData(undefined);
|
||||||
|
init?.onEnded?.(data.data as any);
|
||||||
|
break;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
setStatus(status);
|
setStatus(status);
|
||||||
setData(undefined);
|
setData(undefined);
|
||||||
|
|
@ -297,7 +309,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
default:
|
default:
|
||||||
setData(data.data as DataPayload);
|
setData(data.data as DataPayload);
|
||||||
setStatus(status);
|
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 };
|
return { data, status, error, wsRef: ref };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HandleGoBack ()
|
||||||
|
{
|
||||||
|
if (Router.history.canGoBack())
|
||||||
|
{
|
||||||
|
Router.history.back();
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/mainview/types.d.ts
vendored
1
src/mainview/types.d.ts
vendored
|
|
@ -32,4 +32,5 @@ interface FilterOption extends FocusParams, InteractParams
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
icon?: any;
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
|
||||||
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { JSX } from 'react';
|
import { JSX } from 'react';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
@ -128,27 +129,73 @@ export const EmulatorPackageSchema = z.object({
|
||||||
type: z.enum(['emulator']),
|
type: z.enum(['emulator']),
|
||||||
os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
|
os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
|
||||||
keywords: z.array(z.string()).optional(),
|
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())
|
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 EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
|
||||||
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
||||||
|
export interface EmulatorSourceType
|
||||||
export interface FrontEndEmulator extends Omit<EmulatorPackageType, 'systems'>
|
|
||||||
{
|
{
|
||||||
|
binPath: string;
|
||||||
|
rootPath?: string;
|
||||||
|
type: string;
|
||||||
|
exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontEndEmulator
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
systems: { id: string, name: string, icon: string; }[];
|
systems: { id: string, name: string, icon: string; }[];
|
||||||
gameCount: number;
|
gameCount: number;
|
||||||
exists: boolean;
|
validSource?: EmulatorSourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontEndEmulatorDetailedDownload
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
type: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrontEndEmulatorDetailed extends FrontEndEmulator
|
export interface FrontEndEmulatorDetailed extends FrontEndEmulator
|
||||||
{
|
{
|
||||||
|
homepage: string;
|
||||||
|
description: string;
|
||||||
|
downloads: FrontEndEmulatorDetailedDownload[];
|
||||||
|
keywords?: string[];
|
||||||
screenshots: string[];
|
screenshots: string[];
|
||||||
status: {
|
sources: EmulatorSourceType[];
|
||||||
source?: string;
|
}
|
||||||
location?: string;
|
|
||||||
};
|
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
|
export interface FrontEndGameTypeDetailed extends FrontEndGameType
|
||||||
|
|
@ -157,9 +204,14 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType
|
||||||
fs_size_bytes: number | null;
|
fs_size_bytes: number | null;
|
||||||
missing: boolean;
|
missing: boolean;
|
||||||
local: boolean;
|
local: boolean;
|
||||||
|
genres?: string[];
|
||||||
|
companies?: string[];
|
||||||
|
release_date?: Date;
|
||||||
|
emulators?: FrontEndGameTypeDetailedEmulator[],
|
||||||
achievements?: {
|
achievements?: {
|
||||||
unlocked: number;
|
unlocked: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
entires: FrontEndGameTypeDetailedAchievement[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,6 +247,7 @@ export interface Notification
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
type: 'success' | 'error' | 'info';
|
type: 'success' | 'error' | 'info';
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandEntry
|
export interface CommandEntry
|
||||||
|
|
@ -202,10 +255,15 @@ export interface CommandEntry
|
||||||
id: string | number;
|
id: string | number;
|
||||||
label?: string;
|
label?: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
startDir?: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
emulator?: string;
|
emulator?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
|
||||||
|
|
||||||
export type SettingsType = z.infer<typeof SettingsSchema>;
|
export type SettingsType = z.infer<typeof SettingsSchema>;
|
||||||
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
||||||
export interface GameInstallProgress
|
export interface GameInstallProgress
|
||||||
|
|
@ -229,4 +287,4 @@ export const GameflowPluginSchema = z.object({
|
||||||
launchGame: z.function({ input: [GameLaunchSchema] })
|
launchGame: z.function({ input: [GameLaunchSchema] })
|
||||||
});
|
});
|
||||||
export interface GameflowPlugin extends z.infer<typeof GameflowPluginSchema> { }
|
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';
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
"@schema/*": [
|
"@schema/*": [
|
||||||
"./src/bun/api/schema/*"
|
"./src/bun/api/schema/*"
|
||||||
],
|
],
|
||||||
|
"@queries/*": [
|
||||||
|
"./src/mainview/scripts/queries/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|
|
||||||
BIN
vendors/es-de/emulators.darwin.x64.sqlite
vendored
BIN
vendors/es-de/emulators.darwin.x64.sqlite
vendored
Binary file not shown.
BIN
vendors/es-de/emulators.haiku.x64.sqlite
vendored
BIN
vendors/es-de/emulators.haiku.x64.sqlite
vendored
Binary file not shown.
BIN
vendors/es-de/emulators.linux.arm.sqlite
vendored
BIN
vendors/es-de/emulators.linux.arm.sqlite
vendored
Binary file not shown.
BIN
vendors/es-de/emulators.linux.x64.sqlite
vendored
BIN
vendors/es-de/emulators.linux.x64.sqlite
vendored
Binary file not shown.
BIN
vendors/es-de/emulators.win32.x64.sqlite
vendored
BIN
vendors/es-de/emulators.win32.x64.sqlite
vendored
Binary file not shown.
25
vendors/es-de/systems/android/es_find_rules.xml
vendored
25
vendors/es-de/systems/android/es_find_rules.xml
vendored
|
|
@ -36,6 +36,13 @@
|
||||||
<entry>come.nanodata.armsx2.debug/kr.co.iefriends.pcsx2.MainActivity</entry>
|
<entry>come.nanodata.armsx2.debug/kr.co.iefriends.pcsx2.MainActivity</entry>
|
||||||
</rule>
|
</rule>
|
||||||
</emulator>
|
</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">
|
<emulator name="AZAHAR">
|
||||||
<!-- Nintendo 3DS emulator Azahar -->
|
<!-- Nintendo 3DS emulator Azahar -->
|
||||||
<rule type="androidpackage">
|
<rule type="androidpackage">
|
||||||
|
|
@ -372,6 +379,12 @@
|
||||||
<entry>com.PceEmu/com.imagine.BaseActivity</entry>
|
<entry>com.PceEmu/com.imagine.BaseActivity</entry>
|
||||||
</rule>
|
</rule>
|
||||||
</emulator>
|
</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">
|
<emulator name="PIZZA-BOY-GBA">
|
||||||
<!-- Nintendo Game Boy Advance emulator Pizza Boy GBA -->
|
<!-- Nintendo Game Boy Advance emulator Pizza Boy GBA -->
|
||||||
<rule type="androidpackage">
|
<rule type="androidpackage">
|
||||||
|
|
@ -462,6 +475,12 @@
|
||||||
<entry>com.fms.speccy/com.fms.emulib.TVActivity</entry>
|
<entry>com.fms.speccy/com.fms.emulib.TVActivity</entry>
|
||||||
</rule>
|
</rule>
|
||||||
</emulator>
|
</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">
|
<emulator name="SWAN-EMU">
|
||||||
<!-- Bandai WonderSwan emulator Swan.emu -->
|
<!-- Bandai WonderSwan emulator Swan.emu -->
|
||||||
<rule type="androidpackage">
|
<rule type="androidpackage">
|
||||||
|
|
@ -513,6 +532,12 @@
|
||||||
<entry>com.cmodded.winlator/com.winlator.XServerDisplayActivity</entry>
|
<entry>com.cmodded.winlator/com.winlator.XServerDisplayActivity</entry>
|
||||||
</rule>
|
</rule>
|
||||||
</emulator>
|
</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">
|
<emulator name="YABASANSHIRO-2">
|
||||||
<!-- Sega Saturn emulator Yaba Sanshiro 2 -->
|
<!-- Sega Saturn emulator Yaba Sanshiro 2 -->
|
||||||
<rule type="androidpackage">
|
<rule type="androidpackage">
|
||||||
|
|
|
||||||
19
vendors/es-de/systems/android/es_systems.xml
vendored
19
vendors/es-de/systems/android/es_systems.xml
vendored
|
|
@ -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">%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="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="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>
|
<platform>arcade</platform>
|
||||||
<theme>consolearcade</theme>
|
<theme>consolearcade</theme>
|
||||||
</system>
|
</system>
|
||||||
|
|
@ -1060,6 +1061,7 @@
|
||||||
<fullname>Sega Model 3</fullname>
|
<fullname>Sega Model 3</fullname>
|
||||||
<path>%ROMPATH%/model3</path>
|
<path>%ROMPATH%/model3</path>
|
||||||
<extension>.7z .7Z .zip .ZIP</extension>
|
<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="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>
|
<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>
|
<platform>arcade</platform>
|
||||||
|
|
@ -1426,6 +1428,7 @@
|
||||||
<fullname>PICO-8 Fantasy Console</fullname>
|
<fullname>PICO-8 Fantasy Console</fullname>
|
||||||
<path>%ROMPATH%/pico8</path>
|
<path>%ROMPATH%/pico8</path>
|
||||||
<extension>.p8 .P8 .png .PNG</extension>
|
<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="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="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>
|
<command label="Infinity (Standalone)">%EMULATOR_INFINITY% %ACTION%=android.intent.action.VIEW %DATA%=%ROMPROVIDER%</command>
|
||||||
|
|
@ -1764,7 +1767,7 @@
|
||||||
<name>steam</name>
|
<name>steam</name>
|
||||||
<fullname>Valve Steam</fullname>
|
<fullname>Valve Steam</fullname>
|
||||||
<path>%ROMPATH%/steam</path>
|
<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="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 (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>
|
<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>
|
<name>triforce</name>
|
||||||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||||
<path>%ROMPATH%/triforce</path>
|
<path>%ROMPATH%/triforce</path>
|
||||||
<extension>.7z .7Z .zip .ZIP</extension>
|
<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% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF%</command>
|
||||||
<platform>arcade</platform>
|
<platform>arcade</platform>
|
||||||
<theme>triforce</theme>
|
<theme>triforce</theme>
|
||||||
</system>
|
</system>
|
||||||
|
|
@ -2048,7 +2051,7 @@
|
||||||
<name>windows</name>
|
<name>windows</name>
|
||||||
<fullname>Microsoft Windows</fullname>
|
<fullname>Microsoft Windows</fullname>
|
||||||
<path>%ROMPATH%/windows</path>
|
<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 (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 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>
|
<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>
|
<name>xbox</name>
|
||||||
<fullname>Microsoft Xbox</fullname>
|
<fullname>Microsoft Xbox</fullname>
|
||||||
<path>%ROMPATH%/xbox</path>
|
<path>%ROMPATH%/xbox</path>
|
||||||
<extension>.7z .7Z .zip .ZIP</extension>
|
<extension>.iso .ISO .xiso .XISO</extension>
|
||||||
<command>PLACEHOLDER %ROM%</command>
|
<command label="X1 BOX (Standalone)">%EMULATOR_X1-BOX% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
|
||||||
<platform>xbox</platform>
|
<platform>xbox</platform>
|
||||||
<theme>xbox</theme>
|
<theme>xbox</theme>
|
||||||
</system>
|
</system>
|
||||||
|
|
@ -2130,8 +2133,8 @@
|
||||||
<name>xbox360</name>
|
<name>xbox360</name>
|
||||||
<fullname>Microsoft Xbox 360</fullname>
|
<fullname>Microsoft Xbox 360</fullname>
|
||||||
<path>%ROMPATH%/xbox360</path>
|
<path>%ROMPATH%/xbox360</path>
|
||||||
<extension>.7z .7Z .zip .ZIP</extension>
|
<extension>. .iso .ISO .xex .XEX .zar .ZAR</extension>
|
||||||
<command>PLACEHOLDER %ROM%</command>
|
<command label="aX360e (Standalone)">%EMULATOR_AX360E% %ACTION%=aenu.intent.action.AX360E %EXTRA_game_uri%=%ROMSAF%</command>
|
||||||
<platform>xbox360</platform>
|
<platform>xbox360</platform>
|
||||||
<theme>xbox360</theme>
|
<theme>xbox360</theme>
|
||||||
</system>
|
</system>
|
||||||
|
|
|
||||||
4
vendors/es-de/systems/linux/es_systems.xml
vendored
4
vendors/es-de/systems/linux/es_systems.xml
vendored
|
|
@ -439,6 +439,7 @@
|
||||||
<command label="Play! Disc (Standalone)">%EMULATOR_PLAY!% --fullscreen --disc %ROM%</command>
|
<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 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="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="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="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||||
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||||
|
|
@ -2141,6 +2142,7 @@
|
||||||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||||
<path>%ROMPATH%/triforce</path>
|
<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>
|
<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>
|
<command label="Triforce (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM%</command>
|
||||||
<platform>arcade</platform>
|
<platform>arcade</platform>
|
||||||
<theme>triforce</theme>
|
<theme>triforce</theme>
|
||||||
|
|
@ -2373,7 +2375,7 @@
|
||||||
<name>xbox</name>
|
<name>xbox</name>
|
||||||
<fullname>Microsoft Xbox</fullname>
|
<fullname>Microsoft Xbox</fullname>
|
||||||
<path>%ROMPATH%/xbox</path>
|
<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>
|
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||||
<platform>xbox</platform>
|
<platform>xbox</platform>
|
||||||
<theme>xbox</theme>
|
<theme>xbox</theme>
|
||||||
|
|
|
||||||
|
|
@ -2244,7 +2244,7 @@
|
||||||
<name>xbox</name>
|
<name>xbox</name>
|
||||||
<fullname>Microsoft Xbox</fullname>
|
<fullname>Microsoft Xbox</fullname>
|
||||||
<path>%ROMPATH%/xbox</path>
|
<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>
|
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||||
<platform>xbox</platform>
|
<platform>xbox</platform>
|
||||||
<theme>xbox</theme>
|
<theme>xbox</theme>
|
||||||
|
|
|
||||||
5
vendors/es-de/systems/macos/es_systems.xml
vendored
5
vendors/es-de/systems/macos/es_systems.xml
vendored
|
|
@ -421,6 +421,7 @@
|
||||||
<command label="Play! Disc (Standalone)">%EMULATOR_PLAY!% --fullscreen --disc %ROM%</command>
|
<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 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="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="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||||
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||||
<platform>arcade</platform>
|
<platform>arcade</platform>
|
||||||
|
|
@ -2005,7 +2006,7 @@
|
||||||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||||
<path>%ROMPATH%/triforce</path>
|
<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>
|
<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>
|
<platform>arcade</platform>
|
||||||
<theme>triforce</theme>
|
<theme>triforce</theme>
|
||||||
</system>
|
</system>
|
||||||
|
|
@ -2218,7 +2219,7 @@
|
||||||
<name>xbox</name>
|
<name>xbox</name>
|
||||||
<fullname>Microsoft Xbox</fullname>
|
<fullname>Microsoft Xbox</fullname>
|
||||||
<path>%ROMPATH%/xbox</path>
|
<path>%ROMPATH%/xbox</path>
|
||||||
<extension>.iso .ISO</extension>
|
<extension>.iso .ISO .xiso .XISO</extension>
|
||||||
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
|
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||||
<platform>xbox</platform>
|
<platform>xbox</platform>
|
||||||
<theme>xbox</theme>
|
<theme>xbox</theme>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue