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",
|
||||
"dependencies": {
|
||||
"7zip-min": "^3.0.1",
|
||||
"@auth/core": "^0.34.3",
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.6",
|
||||
|
|
@ -83,6 +84,10 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"7zip-bin": ["7zip-bin@5.1.1", "", {}, "sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ=="],
|
||||
|
||||
"7zip-min": ["7zip-min@3.0.1", "", { "dependencies": { "7zip-bin": "5.1.1" } }, "sha512-WB4VCA/KSKzxhj+BAp8fI3ZYMMAftclkXlUTckuiDacsqyubQxxG3lGcpBcgzWWuJqnfQncEq1xrJpPLSxqsxw=="],
|
||||
|
||||
"@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="],
|
||||
|
||||
"@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="],
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
"packageManager": "bun@1.3.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun run build:vite && bun run ./scripts/dev.ts",
|
||||
"dev": " NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
|
||||
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
||||
"build:vite": "vite build",
|
||||
"build:vite": "bun run --bun vite build",
|
||||
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
||||
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
||||
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
||||
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
||||
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
|
||||
"hmr": "vite --port 5173",
|
||||
"hmr": "bun run --bun vite --port 5173",
|
||||
"drizzle:generate": "bunx drizzle-kit generate",
|
||||
"test": "bun test",
|
||||
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts",
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
"package:Windows": "bun run build:prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-min": "^3.0.1",
|
||||
"@auth/core": "^0.34.3",
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.6",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// watcher.ts - run this instead of --watch
|
||||
import EventEmitter from "events";
|
||||
import browser from '../src/bun/browser';
|
||||
import { tmpdir } from "os";
|
||||
|
|
@ -13,9 +12,9 @@ let retries = 0;
|
|||
|
||||
function spawnServer ()
|
||||
{
|
||||
return Bun.spawn(["bun", "run", '--watch', "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||
return Bun.spawn(["bun", '--watch', '--install=fallback', '--smol', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||
env: {
|
||||
...Bun.env,
|
||||
...process.env,
|
||||
HEADLESS: "true",
|
||||
},
|
||||
stdout: "inherit",
|
||||
|
|
@ -50,7 +49,7 @@ function spawnBrowser ()
|
|||
try
|
||||
{
|
||||
|
||||
return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
|
||||
return browser(events, process.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import { Database } from "bun:sqlite";
|
|||
import * as schema from '../src/bun/api/schema/emulators';
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from 'fs-extra';
|
||||
|
||||
/** get all latest supported romm platforms */
|
||||
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
|
||||
|
|
@ -57,6 +55,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
const emulators = $r('ruleList emulator').toArray().map(s =>
|
||||
{
|
||||
const $emulator = $r(s);
|
||||
const comment = $emulator.contents().toArray().find((node) => node.type === 'comment');
|
||||
const $systempath = $emulator.find('rule[type=systempath] entry');
|
||||
const $staticpath = $emulator.find('rule[type=staticpath] entry');
|
||||
const $corepath = $emulator.find('rule[type=corepath] entry');
|
||||
|
|
@ -66,12 +65,14 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
const emulatorName = $emulator.attr('name');
|
||||
const emulator: typeof schema.emulators.$inferInsert = {
|
||||
name: emulatorName!,
|
||||
fullname: comment?.data.trim(),
|
||||
systempath: $systempath.toArray().map(p => $r(p).text()),
|
||||
staticpath: $staticpath.toArray().map(p => $r(p).text()),
|
||||
corepath: $corepath.toArray().map(p => $r(p).text()),
|
||||
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
|
||||
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
||||
};
|
||||
|
||||
return emulator;
|
||||
});
|
||||
|
||||
|
|
@ -143,6 +144,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
commands,
|
||||
mappings
|
||||
};
|
||||
|
||||
return system;
|
||||
}));
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ import { appPath, getErrorMessage } from "../utils";
|
|||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import UpdateStoreJob from "./jobs/update-store";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -47,6 +48,8 @@ export const customEmulators = new Conf<Record<string, string>>({
|
|||
console.log("Config Path Located At: ", config.path);
|
||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||
console.log("App Directory is ", process.env.APPDIR);
|
||||
console.log("Store Directory is ", getStoreFolder());
|
||||
|
||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { cache } from "./app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
import { GithubReleaseSchema } from "@/shared/constants";
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
ROM_PLATFORMS: 'rom-platforms',
|
||||
|
|
@ -32,3 +33,13 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
|||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getOrCachedGithubRelease (path: string)
|
||||
{
|
||||
return getOrCached(`github-release-${path}`, async () =>
|
||||
{
|
||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return GithubReleaseSchema.parseAsync(await response.json());
|
||||
});
|
||||
}
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { activeGame, config, db, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
|
||||
import z, { number } from "zod";
|
||||
import * as schema from "@schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
import { launchCommand } from "./services/launchGameService";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
||||
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
|
||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||
import { createJimp } from "@jimp/core";
|
||||
import webp from "@jimp/wasm-webp";
|
||||
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
||||
import { use } from "react";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
import { host } from "@/bun/utils/host";
|
||||
|
||||
// A custom jimp that supports webp
|
||||
const Jimp = createJimp({
|
||||
|
|
@ -122,8 +127,43 @@ export default new Elysia()
|
|||
response: z.object({ installed: z.boolean() })
|
||||
})
|
||||
.get('/games', async ({ query, set }) =>
|
||||
{
|
||||
const games: FrontEndGameType[] = [];
|
||||
|
||||
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));
|
||||
|
|
@ -134,11 +174,6 @@ export default new Elysia()
|
|||
where.push(eq(schema.games.source, query.source));
|
||||
}
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
let localGamesSet: Set<string> | undefined;
|
||||
|
||||
if (!query.collection_id)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
...getTableColumns(schema.games),
|
||||
platform: schema.platforms,
|
||||
|
|
@ -153,6 +188,9 @@ export default new Elysia()
|
|||
.where(and(...where));
|
||||
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
|
||||
if (!query.collection_id)
|
||||
{
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
return convertLocalToFrontend(g);
|
||||
|
|
@ -174,31 +212,6 @@ export default new Elysia()
|
|||
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);
|
||||
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));
|
||||
}
|
||||
|
||||
return { games };
|
||||
|
|
@ -231,92 +244,59 @@ export default new Elysia()
|
|||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
const sourceData = await getSourceGameDetailed(source, id);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source === 'local')
|
||||
if (sourceData)
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
if (sourceData.platform_slug)
|
||||
{
|
||||
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||
if (systemMapping)
|
||||
{
|
||||
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
|
||||
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
|
||||
|
||||
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
|
||||
{
|
||||
if (data)
|
||||
{
|
||||
const systems = await buildStoreFrontendEmulatorSystems(data);
|
||||
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
|
||||
}
|
||||
else if (name === 'EMULATORJS')
|
||||
{
|
||||
return {
|
||||
name: 'EMULATORJS',
|
||||
validSource: { binPath: SERVER_URL(host), type: 'js', exists: true },
|
||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
systems: [],
|
||||
gameCount: 0
|
||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
return romGame;
|
||||
return {
|
||||
name: name,
|
||||
logo: "",
|
||||
systems: [],
|
||||
gameCount: 0
|
||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||
}
|
||||
|
||||
return status("Not Found", rom.response);
|
||||
}));
|
||||
}
|
||||
else if (source === 'store')
|
||||
{
|
||||
const gameId = extractStoreGameSourceId(id);
|
||||
const storeGame = await getStoreGame(gameId.system, gameId.id);
|
||||
if (!storeGame) return status("Not Found");
|
||||
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
|
||||
}
|
||||
|
||||
return sourceData;
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
||||
{
|
||||
set.headers["content-type"] = 'text/event-stream';
|
||||
set.headers["cache-control"] = 'no-cache';
|
||||
set.headers['connection'] = 'keep-alive';
|
||||
return buildStatusResponse(source, id);
|
||||
}, {
|
||||
response: z.any(),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.use(buildStatusResponse())
|
||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
|
||||
|
|
@ -332,11 +312,11 @@ export default new Elysia()
|
|||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob))
|
||||
{
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true }));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
|
|
@ -349,7 +329,20 @@ export default new Elysia()
|
|||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
|
||||
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
const job = taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob);
|
||||
if (job)
|
||||
{
|
||||
job.abort('cancel');
|
||||
return status('OK');
|
||||
}
|
||||
return status('Not Found');
|
||||
}, {
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
|
||||
{
|
||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommands)
|
||||
|
|
@ -362,11 +355,11 @@ export default new Elysia()
|
|||
{
|
||||
try
|
||||
{
|
||||
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
|
||||
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
|
||||
if (validCommand)
|
||||
{
|
||||
// launch command waits for the game to exit, we don't want that.
|
||||
launchCommand(validCommand.command, source, id, validCommands.gameId);
|
||||
launchCommand(validCommand, source, id, validCommands.gameId);
|
||||
return { type: 'application', command: null };
|
||||
} else
|
||||
{
|
||||
|
|
@ -382,7 +375,7 @@ export default new Elysia()
|
|||
}
|
||||
}, {
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ command_id: z.number().or(z.string()).optional() }),
|
||||
body: z.object({ command_id: z.number().or(z.string()).optional() }),
|
||||
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
|
||||
})
|
||||
.post("/stop", async ({ }) =>
|
||||
|
|
@ -404,4 +397,190 @@ export default new Elysia()
|
|||
.get('/emulatorjs/data/*', async () =>
|
||||
{
|
||||
return status("Not Found");
|
||||
})
|
||||
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const emulator = await getStoreEmulatorPackage(id);
|
||||
if (!emulator) return status("Not Found");
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||
const systemsIdSet = new Set(systems.map(s => s.id));
|
||||
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
|
||||
let localGamesSet: Set<string> | undefined;
|
||||
|
||||
const localGames = await db.select({
|
||||
...getTableColumns(schema.games),
|
||||
platform: schema.platforms,
|
||||
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
|
||||
})
|
||||
.from(schema.games)
|
||||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||
.groupBy(schema.games.id)
|
||||
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
|
||||
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
return convertLocalToFrontend(g);
|
||||
}).slice(0, 3));
|
||||
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
|
||||
|
||||
if (rommPlatforms)
|
||||
{
|
||||
const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id);
|
||||
if (platformIds.length > 0)
|
||||
{
|
||||
const rommGames = await getRomsApiRomsGet({
|
||||
query: {
|
||||
platform_ids: platformIds
|
||||
}
|
||||
});
|
||||
|
||||
let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size);
|
||||
|
||||
for (const slug of systemsRommSlugSet)
|
||||
{
|
||||
const systemRommGames = rommGames.data?.items.filter(g => !localGamesSet?.has(`romm@${g.id}`) && slug === g.platform_slug).map(g =>
|
||||
{
|
||||
return convertRomToFrontend(g);
|
||||
}).slice(0, gamesPerSystem) ?? [];
|
||||
games.push(...systemRommGames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gamesManifest = await getStoreGameManifest();
|
||||
const storeGames = await Promise.all(gamesManifest
|
||||
.filter(g => systemsIdSet.has(path.dirname(g.path)))
|
||||
.map(async (e) =>
|
||||
{
|
||||
const system = path.dirname(e.path);
|
||||
const id = path.basename(e.path, path.extname(e.path));
|
||||
|
||||
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
|
||||
|
||||
if (localGame)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeGame = await getStoreGameFromPath(e.path);
|
||||
|
||||
return convertStoreToFrontend(system, id, storeGame);
|
||||
}));
|
||||
|
||||
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
|
||||
|
||||
return games;
|
||||
})
|
||||
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const sourceData = await getSourceGameDetailed(source, id);
|
||||
if (!sourceData) return status("Not Found");
|
||||
|
||||
const sourceCompaniesSet = new Set(sourceData.companies);
|
||||
const sourceGenresSet = new Set(sourceData.genres);
|
||||
|
||||
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
|
||||
|
||||
const games: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||
|
||||
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
|
||||
.from(schema.games)
|
||||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||
.groupBy(schema.games.id);
|
||||
|
||||
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
|
||||
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata })));
|
||||
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
|
||||
if (rommPlatforms)
|
||||
{
|
||||
const rommPlatform = rommPlatforms.find(p => p.slug === sourceData.platform_slug);
|
||||
if (rommPlatform)
|
||||
{
|
||||
const rommGames = await getRomsApiRomsGet({ query: { genres: sourceData.genres, genres_logic: 'any' } });
|
||||
if (rommGames.data)
|
||||
{
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSourceSet.has(`romm@${g.id}`)).map(g => ({ ...convertRomToFrontend(g), metadata: g.metadatum })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shuffledGames = await getShuffledStoreGames();
|
||||
const storeGames = await Promise.all(shuffledGames
|
||||
.filter(g =>
|
||||
{
|
||||
const system = path.dirname(g.path);
|
||||
const id = path.basename(g.path, path.extname(g.path));
|
||||
|
||||
if (localGamesSourceSet.has(`${system}@${id}`))
|
||||
return false;
|
||||
|
||||
if (esSystem)
|
||||
{
|
||||
if (path.dirname(g.path) === esSystem.system) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map(async (e) =>
|
||||
{
|
||||
const system = path.dirname(e.path);
|
||||
const id = path.basename(e.path, path.extname(e.path));
|
||||
const storeGame = await getStoreGameFromPath(e.path);
|
||||
return convertStoreToFrontend(system, id, storeGame);
|
||||
}));
|
||||
|
||||
if (storeGames)
|
||||
{
|
||||
games.push(...storeGames.slice(0, 3));
|
||||
}
|
||||
|
||||
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
|
||||
|
||||
const rankedGames = games.filter(g =>
|
||||
{
|
||||
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).map(g =>
|
||||
{
|
||||
let rank = random.next();
|
||||
|
||||
if (g.platform_slug === sourceData.platform_slug)
|
||||
rank += 1;
|
||||
|
||||
if (g.metadata)
|
||||
{
|
||||
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
|
||||
{
|
||||
rank += 1;
|
||||
}
|
||||
|
||||
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
|
||||
{
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { rank: rank, game: g };
|
||||
});
|
||||
|
||||
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
||||
|
||||
return rankedGames.map(g => g.game).slice(0, 10);
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns } from "drizzle-orm";
|
||||
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "@schema/app";
|
||||
|
|
@ -25,17 +25,35 @@ export default new Elysia()
|
|||
{
|
||||
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
|
||||
{
|
||||
const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } });
|
||||
const screenshots: string[] = [];
|
||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data);
|
||||
if (rommGames)
|
||||
{
|
||||
const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`);
|
||||
if (rommScreenshots)
|
||||
screenshots.push(...rommScreenshots);
|
||||
}
|
||||
|
||||
if (screenshots.length <= 0)
|
||||
{
|
||||
const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1);
|
||||
|
||||
if (localScreenshots)
|
||||
screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`));
|
||||
}
|
||||
|
||||
const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id);
|
||||
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.display_name,
|
||||
family_name: p.family_name,
|
||||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
game_count: p.rom_count + localGames.length,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: String(p.id) },
|
||||
hasLocal: localPlatformSet.has(p.slug),
|
||||
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
|
||||
paths_screenshots: screenshots
|
||||
};
|
||||
|
||||
return platform;
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||
import * as schema from '@schema/emulators';
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||
import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||
import os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
import { CommandEntry } from '@/shared/constants';
|
||||
import { CommandEntry, EmulatorSourceType } from '@/shared/constants';
|
||||
import { cores } from '../../emulatorjs/emulatorjs';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
|
||||
export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number)
|
||||
{
|
||||
if (activeGame && activeGame.process?.killed === false)
|
||||
{
|
||||
|
|
@ -31,8 +33,9 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
|||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const game = spawn(validCommand, {
|
||||
shell: true
|
||||
const game = spawn(validCommand.command, {
|
||||
shell: true,
|
||||
cwd: validCommand.startDir
|
||||
});
|
||||
game.stdout.on('data', data => console.log(data));
|
||||
game.on('close', (code) =>
|
||||
|
|
@ -99,6 +102,54 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
|||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the emulators related to the given system
|
||||
* @param systemSlug the ES-DE slug for the system
|
||||
*/
|
||||
export async function getEmulatorsForSystem (systemSlug: string)
|
||||
{
|
||||
const system = await emulatorsDb.query.systems.findFirst({
|
||||
with: { commands: true },
|
||||
where: eq(schema.systems.name, systemSlug)
|
||||
});
|
||||
|
||||
if (!system)
|
||||
{
|
||||
throw new Error(`Could not find system '${systemSlug}'`);
|
||||
}
|
||||
|
||||
const emulators = new Set<string>();
|
||||
await Promise.all(system.commands.map(async (command, index) =>
|
||||
{
|
||||
let cmd = command.command;
|
||||
|
||||
const matches = Array.from(cmd.matchAll(varRegex));
|
||||
matches.forEach(([value]) =>
|
||||
{
|
||||
if (value.startsWith("%EMULATOR_"))
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
emulators.add(emulatorName);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
|
||||
if (cores[systemSlug])
|
||||
{
|
||||
emulators.add('EMULATORJS');
|
||||
}
|
||||
|
||||
return Array.from(emulators);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param data Uses es-de system slug
|
||||
* @returns
|
||||
*/
|
||||
export async function getValidLaunchCommands (data: {
|
||||
systemSlug: string;
|
||||
gamePath: string;
|
||||
|
|
@ -160,7 +211,17 @@ export async function getValidLaunchCommands (data: {
|
|||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
|
||||
function escapeWindowsArg (arg: string): string
|
||||
{
|
||||
return `"${arg
|
||||
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
||||
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
|
||||
}"`;
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands
|
||||
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
|
||||
.map(async (command, index) =>
|
||||
{
|
||||
const label = command.label;
|
||||
let cmd = command.command;
|
||||
|
|
@ -171,14 +232,18 @@ export async function getValidLaunchCommands (data: {
|
|||
if (cmd.includes('%ESCAPESPECIALS%'))
|
||||
rom = rom.replace(/[&()^=;,]/g, '');
|
||||
|
||||
|
||||
|
||||
const staticVars: Record<string, string> = {
|
||||
'%ROM%': $.escape(rom),
|
||||
'%ROM%': escapeWindowsArg(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]))
|
||||
'%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) =>
|
||||
|
|
@ -208,14 +273,11 @@ export async function getValidLaunchCommands (data: {
|
|||
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' };
|
||||
}
|
||||
let execs = await findExecsByName(emulatorName);
|
||||
let validExec = execs.find(e => e.exists);
|
||||
|
||||
emulator = emulatorName;
|
||||
return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
|
||||
return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
|
|
@ -223,8 +285,21 @@ export async function getValidLaunchCommands (data: {
|
|||
}));
|
||||
|
||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||
vars['%ESCAPESPECIALS%'] = "";
|
||||
vars['%HIDEWINDOW%'] = '';
|
||||
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 "";
|
||||
});
|
||||
}
|
||||
|
||||
// missing variable
|
||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||
|
|
@ -235,6 +310,7 @@ export async function getValidLaunchCommands (data: {
|
|||
id: index,
|
||||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
startDir,
|
||||
valid: !invalid, emulator
|
||||
} satisfies CommandEntry;
|
||||
}));
|
||||
|
|
@ -242,19 +318,44 @@ export async function getValidLaunchCommands (data: {
|
|||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExecByName (emulatorName: string)
|
||||
export async function findExecsByName (emulatorName: string)
|
||||
{
|
||||
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||
if (!emulator)
|
||||
{
|
||||
throw new Error(`Could not find emulator ${emulatorName}`);
|
||||
}
|
||||
return findExec(emulator);
|
||||
return findExecs(emulatorName, emulator);
|
||||
}
|
||||
|
||||
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined
|
||||
{
|
||||
if (os.platform() === 'win32')
|
||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
|
||||
if (storeExecName)
|
||||
{
|
||||
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
{
|
||||
const execs: EmulatorSourceType[] = [];
|
||||
|
||||
if (customEmulators.has(id))
|
||||
{
|
||||
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
|
||||
}
|
||||
|
||||
if (emulator && emulator.systempath.length > 0)
|
||||
{
|
||||
const storePath = findStoreEmulatorExec(id, emulator);
|
||||
if (storePath) execs.push(storePath);
|
||||
}
|
||||
|
||||
if (emulator && os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
if (regValues.length > 0)
|
||||
|
|
@ -264,32 +365,32 @@ export async function findExec (emulator: { winregistrypath: string[], systempat
|
|||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return { path: registryValue, type: 'registry' };
|
||||
execs.push({ binPath: registryValue, type: 'registry', exists: true });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const systempaths = emulator.systempath;
|
||||
if (systempaths.length > 0)
|
||||
if (emulator && emulator.systempath.length > 0)
|
||||
{
|
||||
const systemPath = await resolveSystemPath(systempaths);
|
||||
const systemPath = await resolveSystemPath(emulator.systempath);
|
||||
if (systemPath)
|
||||
{
|
||||
return { path: systemPath, type: 'system' };
|
||||
execs.push({ binPath: systemPath, type: 'system', exists: true });
|
||||
}
|
||||
}
|
||||
|
||||
const staticPaths = emulator.staticpath;
|
||||
if (staticPaths.length > 0)
|
||||
if (emulator && emulator.staticpath.length > 0)
|
||||
{
|
||||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
const staticPath = await resolveStaticPath(emulator.staticpath);
|
||||
if (staticPath)
|
||||
{
|
||||
return { path: staticPath, type: 'static' };
|
||||
execs.push({ binPath: staticPath, type: 'static', exists: true });
|
||||
}
|
||||
}
|
||||
|
||||
return execs;
|
||||
}
|
||||
|
||||
async function readRegistryValue (text: string)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { ErrorLike } from "elysia/universal";
|
|||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||
import { cores } from "../../emulatorjs/emulatorjs";
|
||||
import { host } from "@/bun/utils/host";
|
||||
import Elysia from "elysia";
|
||||
import z from "zod";
|
||||
import data from "@emulators";
|
||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -54,8 +58,11 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
|
|||
{
|
||||
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
|
||||
commands.push({
|
||||
id: 'emulatorjs',
|
||||
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
|
||||
id: 'EMULATORJS',
|
||||
label: "Emulator JS",
|
||||
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
||||
valid: true,
|
||||
emulator: 'EMULATORJS'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -89,97 +96,93 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: string)
|
||||
export default function buildStatusResponse ()
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
let closed = false;
|
||||
return new Response(new ReadableStream({
|
||||
async start (controller)
|
||||
return new Elysia().ws('/status/:source/:id', {
|
||||
response: z.discriminatedUnion('status', [
|
||||
z.object({ status: z.literal('error'), error: z.unknown() }),
|
||||
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
|
||||
z.object({ status: z.literal(['refresh', 'queued']) }),
|
||||
z.object({ status: z.literal('playing'), details: z.string() }),
|
||||
z.object({ status: z.literal('install'), details: z.string() }),
|
||||
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
|
||||
]),
|
||||
message (ws, data)
|
||||
{
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
||||
if (data === 'cancel')
|
||||
{
|
||||
if (closed) return;
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||
activeTask?.abort('cancel');
|
||||
}
|
||||
|
||||
await sendLatests();
|
||||
|
||||
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
||||
const keepAlive = setInterval(() =>
|
||||
},
|
||||
async open (ws)
|
||||
{
|
||||
if (closed) return clearInterval(keepAlive);
|
||||
try
|
||||
{
|
||||
enqueue({}, 'ping');
|
||||
} catch
|
||||
{
|
||||
closed = true;
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const sourceId = `${source}-${id}`;
|
||||
sendLatests();
|
||||
|
||||
async function sendLatests ()
|
||||
{
|
||||
if (closed) return;
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
||||
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
||||
if (ws.readyState > 1) return;
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } });
|
||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
||||
if (activeTask)
|
||||
{
|
||||
enqueue({
|
||||
progress: activeTask.progress,
|
||||
status: activeTask.state as any
|
||||
});
|
||||
if (activeTask.status === 'queued')
|
||||
{
|
||||
ws.send({ status: 'queued' });
|
||||
} else
|
||||
{
|
||||
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
||||
}
|
||||
|
||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
||||
{
|
||||
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
|
||||
ws.send({ status: 'playing', details: 'Playing' });
|
||||
}
|
||||
else
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
||||
if (validCommand)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
{
|
||||
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
|
||||
ws.send({ status: 'error', error: validCommand.message });
|
||||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
|
||||
ws.send({
|
||||
status: 'installed',
|
||||
details: validCommand.commands[0].label,
|
||||
commands: validCommand.commands
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
else if (source === 'romm')
|
||||
else if (ws.data.params.source === 'romm')
|
||||
{
|
||||
// TODO: Add Caching
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(ws.data.params.id) } });
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||
{
|
||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
ws.send({ status: 'install', details: 'Install' });
|
||||
}
|
||||
|
||||
} else if (source === 'store')
|
||||
} else if (ws.data.params.source === 'store')
|
||||
{
|
||||
const storeGame = await getStoreGameFromId(id);
|
||||
const storeGame = await getStoreGameFromId(ws.data.params.id);
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
const size = Number(fileResponse.headers.get('content-length'));
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
|
||||
if (size > stats.bsize * stats.bavail)
|
||||
{
|
||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
ws.send({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,50 +193,56 @@ export default async function buildStatusResponse (source: string, id: string)
|
|||
{
|
||||
if (data.error)
|
||||
{
|
||||
enqueue({
|
||||
ws.send({
|
||||
status: 'error',
|
||||
error: data.error
|
||||
}, 'error');
|
||||
});
|
||||
}
|
||||
await sendLatests();
|
||||
};
|
||||
events.on('activegameexit', handleActiveExit);
|
||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
||||
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
|
||||
dispose.push(taskQueue.on('progress', (data) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
{
|
||||
enqueue({ progress, status: state as any });
|
||||
|
||||
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('completed', ({ id }) =>
|
||||
dispose.push(taskQueue.on('queued', (data) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
{
|
||||
enqueue({}, 'refresh');
|
||||
ws.send({ status: 'queued' });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('error', ({ id, error }) =>
|
||||
dispose.push(taskQueue.on('completed', (data) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
{
|
||||
enqueue({
|
||||
ws.send({ status: 'refresh' });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('error', (data) =>
|
||||
{
|
||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
||||
{
|
||||
ws.send({
|
||||
status: 'error',
|
||||
error: getErrorMessage(error)
|
||||
}, 'error');
|
||||
error: getErrorMessage(data.error)
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
cleanup = () =>
|
||||
(ws.data as any).cleanup = () =>
|
||||
{
|
||||
closed = true;
|
||||
dispose.forEach(f => f());
|
||||
};
|
||||
},
|
||||
cancel ()
|
||||
close (ws, code, reason)
|
||||
{
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
(ws.data as any).cleanup?.();
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config, emulatorsDb } from "../../app";
|
||||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import romm from "@/mainview/scripts/queries/romm";
|
||||
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
|
|
@ -127,7 +129,7 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
|
|||
slug: null,
|
||||
name: storeGame.title,
|
||||
platform_id: null,
|
||||
platform_slug: system,
|
||||
platform_slug: rommSystem?.sourceSlug ?? system,
|
||||
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
|
||||
};
|
||||
|
||||
|
|
@ -157,21 +159,138 @@ export async function convertStoreToFrontendDetailed (system: string, id: string
|
|||
return detailed;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
export async function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...convertRomToFrontend(rom),
|
||||
summary: rom.summary,
|
||||
fs_size_bytes: rom.fs_size_bytes,
|
||||
local: false,
|
||||
missing: rom.missing_from_fs
|
||||
missing: rom.missing_from_fs,
|
||||
genres: rom.metadatum.genres,
|
||||
companies: rom.metadatum.companies,
|
||||
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined
|
||||
};
|
||||
|
||||
const userData = await getCurrentUserApiUsersMeGet();
|
||||
const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id);
|
||||
|
||||
if (rom.merged_ra_metadata?.achievements)
|
||||
{
|
||||
const earnedMap = new Map<string, { date: Date; date_hardcode?: Date; }>(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }]));
|
||||
detailed.achievements = {
|
||||
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
|
||||
unlocked: gameAchievements?.num_awarded ?? 0,
|
||||
entires: rom.merged_ra_metadata.achievements.map(a =>
|
||||
{
|
||||
const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined;
|
||||
const ach: FrontEndGameTypeDetailedAchievement = {
|
||||
id: a.badge_id ?? String(a.ra_id) ?? 'unknown',
|
||||
title: a.title ?? "Unknown",
|
||||
badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined,
|
||||
date: earned?.date,
|
||||
date_hardcode: earned?.date_hardcode,
|
||||
description: a.description ?? undefined,
|
||||
display_order: a.display_order ?? 0,
|
||||
type: a.type ?? undefined
|
||||
};
|
||||
|
||||
return ach;
|
||||
}).sort((a, b) => a.display_order - b.display_order),
|
||||
total: rom.merged_ra_metadata.achievements.length
|
||||
};
|
||||
}
|
||||
return detailed;
|
||||
}
|
||||
|
||||
export async function getLocalGameDetailed (match: any)
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: match,
|
||||
with: {
|
||||
screenshots: { columns: { id: true } },
|
||||
platform: { columns: { name: true, slug: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (localGame)
|
||||
{
|
||||
const exists = await checkInstalled(localGame.path_fs);
|
||||
const fileSize = await calculateSize(localGame.path_fs);
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
|
||||
updated_at: localGame.created_at,
|
||||
id: { id: String(localGame.id), source: 'local' },
|
||||
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
|
||||
fs_size_bytes: fileSize ?? null,
|
||||
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
|
||||
local: true,
|
||||
missing: !exists,
|
||||
platform_display_name: localGame.platform?.name,
|
||||
summary: localGame.summary,
|
||||
source: localGame.source,
|
||||
source_id: localGame.source_id,
|
||||
path_fs: localGame.path_fs,
|
||||
last_played: localGame.last_played,
|
||||
slug: localGame.slug,
|
||||
name: localGame.name,
|
||||
platform_id: localGame.platform_id,
|
||||
platform_slug: localGame.platform.slug
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getSourceGameDetailed (source: string, id: string)
|
||||
{
|
||||
if (source === 'local')
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||
if (localGame) return localGame;
|
||||
return undefined;
|
||||
}
|
||||
else
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (source === 'romm')
|
||||
{
|
||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = await convertRomToFrontendDetailed(rom.data);
|
||||
if (localGame)
|
||||
{
|
||||
return {
|
||||
...romGame,
|
||||
...localGame,
|
||||
};
|
||||
}
|
||||
return romGame;
|
||||
}
|
||||
else if (localGame)
|
||||
{
|
||||
return localGame;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
else if (source === 'store')
|
||||
{
|
||||
const gameId = extractStoreGameSourceId(id);
|
||||
const storeGame = await getStoreGame(gameId.system, gameId.id);
|
||||
if (!storeGame) return undefined;
|
||||
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
|
||||
if (localGame)
|
||||
{
|
||||
return { ...storeFrontendGame, ...localGame };
|
||||
}
|
||||
return storeFrontendGame;
|
||||
} else if (localGame)
|
||||
{
|
||||
return localGame;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
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 path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, jar } from "../app";
|
||||
import unzip from 'unzip-stream';
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { config, db, emulatorsDb, events, jar } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
import { hashFile } from "@/bun/utils";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { sleep } from "bun";
|
||||
import _7z from '7zip-min';
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -19,13 +21,16 @@ interface JobConfig
|
|||
dryDownload?: boolean;
|
||||
}
|
||||
|
||||
export class InstallJob implements IJob
|
||||
export type InstallJobStates = 'download' | 'extract';
|
||||
|
||||
export class InstallJob implements IJob<never, InstallJobStates>
|
||||
{
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
public group = InstallJob.id;
|
||||
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
{
|
||||
|
|
@ -35,16 +40,19 @@ export class InstallJob implements IJob
|
|||
this.source = source;
|
||||
}
|
||||
|
||||
public async start (cx: JobContext)
|
||||
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
|
||||
{
|
||||
cx.setProgress(0, 'download');
|
||||
fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||
|
||||
if (this.config?.dryRun !== true)
|
||||
{
|
||||
const downloadPath = config.get('downloadPath');
|
||||
|
||||
let downloadUrl: URL;
|
||||
let files: {
|
||||
url: URL,
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
size?: number;
|
||||
}[] = [];
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
|
|
@ -59,6 +67,7 @@ export class InstallJob implements IJob
|
|||
let source_id: string;
|
||||
let system_slug: string;
|
||||
let extract_path: string;
|
||||
let metadata: any | undefined;
|
||||
|
||||
switch (this.source)
|
||||
{
|
||||
|
|
@ -80,9 +89,32 @@ export class InstallJob implements IJob
|
|||
slug = rom.slug;
|
||||
system_slug = rommPlatform.slug;
|
||||
extract_path = '';
|
||||
metadata = rom.metadatum;
|
||||
|
||||
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
|
||||
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':
|
||||
|
|
@ -90,107 +122,42 @@ export class InstallJob implements IJob
|
|||
const gameId = extractStoreGameSourceId(this.gameId);
|
||||
coverUrl = game.pictures.titlescreens[0];
|
||||
screenshotUrls = game.pictures.screenshots;
|
||||
downloadUrl = new URL(game.file);
|
||||
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 = 'roms', gameId.system;
|
||||
extract_path = path.join('roms', gameId.system);
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported source");
|
||||
}
|
||||
|
||||
if (this.config?.dryRun !== true)
|
||||
{
|
||||
if (this.config?.dryDownload !== true)
|
||||
{
|
||||
/*
|
||||
// download files for rom
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
},
|
||||
fileName: `${this.id}.zip`,
|
||||
// Romm doesn't support resume download
|
||||
override: true
|
||||
});
|
||||
|
||||
cx.abortSignal.addEventListener('abort', downloader.stop);
|
||||
|
||||
downloader.on('progress.throttled', e =>
|
||||
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
|
||||
files,
|
||||
config.get('downloadPath'),
|
||||
{
|
||||
cx.setProgress(e.progress, 'download');
|
||||
});
|
||||
|
||||
downloader.on('error', (e) =>
|
||||
signal: cx.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
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
|
||||
cx.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const totalBytes = Number(res.headers.get("content-length")) || 0;
|
||||
let bytesReceived = 0;
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform (chunk, _, callback)
|
||||
const downloadedFiles = await downloader.start();
|
||||
if (extract_path && downloadedFiles)
|
||||
{
|
||||
bytesReceived += chunk.length;
|
||||
if (totalBytes > 0)
|
||||
for (const path of downloadedFiles)
|
||||
{
|
||||
const percent = (bytesReceived / totalBytes) * 100;
|
||||
cx.setProgress(percent, 'download');
|
||||
await _7z.unpack(path, extract_path);
|
||||
}
|
||||
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)
|
||||
.on('close', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config?.dryDownload === true)
|
||||
|
|
@ -198,8 +165,6 @@ export class InstallJob implements IJob
|
|||
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||
|
||||
|
|
@ -291,7 +256,8 @@ export class InstallJob implements IJob
|
|||
summary: summary,
|
||||
name,
|
||||
cover,
|
||||
cover_type: coverResponse.headers.get('content-type')
|
||||
cover_type: coverResponse.headers.get('content-type'),
|
||||
metadata
|
||||
};
|
||||
|
||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||
|
|
@ -327,7 +293,17 @@ export class InstallJob implements IJob
|
|||
}
|
||||
|
||||
});
|
||||
} else
|
||||
{
|
||||
for (let i = 0; i < 10; i++)
|
||||
{
|
||||
cx.setProgress(i * 10, "download");
|
||||
if (cx.abortSignal.aborted) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
events.emit('notification', { message: `${name}: Installed`, type: 'success', duration: 8000 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { } from "zod";
|
||||
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
import UpdateStoreJob from "./update-store";
|
||||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { IJob } from "../task-queue";
|
||||
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (_job: T, path: Path, dataSchema: TS)
|
||||
function registerJob<
|
||||
const Path extends string,
|
||||
const Schema extends ZodTypeAny,
|
||||
const States extends string,
|
||||
T extends IJob<z.infer<Schema>, States>
|
||||
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
|
||||
{
|
||||
return new Elysia().ws(path, {
|
||||
return new Elysia().ws(_job.id, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
|
|
@ -16,14 +24,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
progress: z.number(),
|
||||
data: dataSchema
|
||||
data: _job.dataSchema
|
||||
}),
|
||||
z.object({ type: z.literal(['completed', 'ended']) }),
|
||||
z.object({ type: z.literal('error'), error: z.unknown() })
|
||||
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
|
||||
z.object({ type: z.literal('error'), error: z.string() })
|
||||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(path);
|
||||
const job = taskQueue.findJob(_job.id, _job);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
|
|
@ -32,30 +40,37 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id }) =>
|
||||
taskQueue.on('completed', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'completed' });
|
||||
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('ended', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'error', error: error });
|
||||
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
})
|
||||
];
|
||||
|
|
@ -68,13 +83,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(path)?.abort('cancel');
|
||||
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
|
||||
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
|
||||
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
|
||||
.use(registerJob(LoginJob))
|
||||
.use(registerJob(TwitchLoginJob))
|
||||
.use(registerJob(UpdateStoreJob))
|
||||
.use(registerJob(EmulatorDownloadJob));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
|
||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
|
|
@ -8,7 +8,7 @@ import { config } from "../app";
|
|||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
export class LoginJob implements IJob
|
||||
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
|
||||
{
|
||||
endsAt: Date;
|
||||
startedAt: Date;
|
||||
|
|
@ -25,7 +25,7 @@ export class LoginJob implements IJob
|
|||
|
||||
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
async start (context: JobContext<LoginJob, z.infer<typeof LoginJob.dataSchema>, "base">): Promise<void>
|
||||
{
|
||||
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||
.use(cors())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ interface TwitchDevice
|
|||
verification_uri: string;
|
||||
}
|
||||
|
||||
export default class TwitchLoginJob implements IJob
|
||||
type States = "Retrieving Device" | "Waiting For Authentication";
|
||||
|
||||
export default class TwitchLoginJob implements IJob<z.infer<typeof TwitchLoginJob.dataSchema>, States>
|
||||
{
|
||||
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||
device?: TwitchDevice;
|
||||
|
|
@ -38,7 +40,7 @@ export default class TwitchLoginJob implements IJob
|
|||
user_code: this.device.user_code
|
||||
}) : undefined;
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
async start (context: JobContext<TwitchLoginJob, z.infer<typeof TwitchLoginJob.dataSchema>, States>): Promise<any>
|
||||
{
|
||||
context.setProgress(0, "Retrieving Device");
|
||||
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { getStoreFolder } from "../store/store";
|
||||
import { getStoreFolder } from "../store/services/gamesService";
|
||||
import z from "zod";
|
||||
|
||||
export default class UpdateStoreJob implements IJob
|
||||
export default class UpdateStoreJob implements IJob<never, never>
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
||||
static branch = "master";
|
||||
static dataSchema = z.never();
|
||||
|
||||
async gitCommand (commands: string[], dir: string)
|
||||
{
|
||||
|
|
@ -40,8 +42,10 @@ export default class UpdateStoreJob implements IJob
|
|||
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
|
||||
}
|
||||
|
||||
async start (context: JobContext)
|
||||
async start (context: JobContext<UpdateStoreJob, never, never>)
|
||||
{
|
||||
if (process.env.CUSTOM_STORE_PATH) return;
|
||||
|
||||
const storeFolder = getStoreFolder();
|
||||
await ensureDir(storeFolder);
|
||||
context.setProgress(10);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|||
|
||||
export const emulators = sqliteTable('emulators', {
|
||||
name: text().primaryKey().unique(),
|
||||
fullname: text(),
|
||||
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
staticpath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
|
||||
import * as appSchema from '@schema/app';
|
||||
import { findExecByName } from "../games/services/launchGameService";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { customEmulators, db, emulatorsDb } from '../app';
|
||||
import fs from 'node:fs/promises';
|
||||
import { cores } from '../emulatorjs/emulatorjs';
|
||||
import { FrontEndEmulator } from '@/shared/constants';
|
||||
import { FrontEndEmulator, SERVER_URL } from '@/shared/constants';
|
||||
import { findExecsByName } from '../games/services/launchGameService';
|
||||
import { host } from '@/bun/utils/host';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
|
|
@ -53,14 +54,8 @@ export async function getRelevantEmulators ()
|
|||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
const execPaths = await findExecsByName(emulator);
|
||||
const validExecPath = execPaths.find(e => e.exists);
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
const validSystemSlug = system_slug.find(s => s.system);
|
||||
|
|
@ -68,45 +63,31 @@ export async function getRelevantEmulators ()
|
|||
{
|
||||
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
|
||||
if (exists)
|
||||
if (validExecPath)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = {
|
||||
const em: FrontEndEmulator & { isCritical: boolean; } = {
|
||||
name: emulator,
|
||||
exists: exists,
|
||||
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
||||
gameCount: 0,
|
||||
description: '',
|
||||
homepage: '',
|
||||
type: 'emulator',
|
||||
os: [process.platform as any],
|
||||
isCritical: false,
|
||||
path: execPath,
|
||||
validSource: validExecPath
|
||||
};
|
||||
|
||||
return em;
|
||||
}));
|
||||
|
||||
finalEmulators.push({
|
||||
name: 'emulatorjs',
|
||||
exists: true,
|
||||
path: { path: 'localhost', type: 'js' },
|
||||
name: 'EMULATORJS',
|
||||
validSource: { binPath: `${SERVER_URL(host)}`, type: 'js', exists: true },
|
||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
systems: [],
|
||||
gameCount: 0,
|
||||
type: 'emulator',
|
||||
description: '',
|
||||
homepage: '',
|
||||
os: [process.platform as any],
|
||||
isCritical: false
|
||||
isCritical: false,
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
|
|
|
|||
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 { and, eq } from "drizzle-orm";
|
||||
import { config, emulatorsDb } from '../../app';
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { shuffleInPlace } from "@/bun/utils";
|
||||
|
||||
export async function getShuffledStoreGames ()
|
||||
{
|
||||
return getOrCached('shuffled-store-games', async () =>
|
||||
{
|
||||
const gamesManifest = await getStoreGameManifest();
|
||||
const allStoreGames = gamesManifest.filter(g => g.type === 'blob');
|
||||
shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60));
|
||||
return allStoreGames;
|
||||
}, { expireMs: 1000 / 60 / 60 });
|
||||
}
|
||||
|
||||
export async function getStoreGameManifest ()
|
||||
{
|
||||
|
|
@ -57,3 +74,54 @@ export async function getStoreGameFromPath (path: string)
|
|||
.then(g => StoreGameSchema.parseAsync(g)));
|
||||
return game;
|
||||
}
|
||||
|
||||
export function getStoreFolder ()
|
||||
{
|
||||
if (process.env.CUSTOM_STORE_PATH) return process.env.CUSTOM_STORE_PATH;
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return path.join(downlodDir, "store");
|
||||
}
|
||||
|
||||
export async function getStoreEmulatorPackage (id: string)
|
||||
{
|
||||
const emulatorPath = path.join(getStoreFolder(), "buckets", "emulators", `${id}.json`);
|
||||
if (await fs.exists(emulatorPath))
|
||||
return EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getAllStoreEmulatorPackages ()
|
||||
{
|
||||
const emulatorsBucket = path.join(getStoreFolder(), "buckets", "emulators");
|
||||
const emulators = await fs.readdir(emulatorsBucket);
|
||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||
|
||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
||||
{
|
||||
if (e.error)
|
||||
{
|
||||
console.error(e.error);
|
||||
}
|
||||
return e.data;
|
||||
}).map(e => e.data!);
|
||||
|
||||
return emulatesParsed;
|
||||
}
|
||||
|
||||
export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType)
|
||||
{
|
||||
const systems = await Promise.all(emulator.systems.map(async system =>
|
||||
{
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
|
||||
});
|
||||
|
||||
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
|
||||
|
||||
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
|
||||
|
||||
return { id: system, romm_slug: rommSystem?.sourceSlug, name: esSystem?.fullname ?? system, icon: icon };
|
||||
}));
|
||||
|
||||
return systems;
|
||||
}
|
||||
|
|
@ -1,61 +1,19 @@
|
|||
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db } from "../app";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, db, taskQueue } from "../app";
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
|
||||
import { findExec } from "../games/services/launchGameService";
|
||||
import { emulatorsDb } from '../app';
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
|
||||
import { findExecsByName } from "../games/services/launchGameService";
|
||||
import * as appSchema from '@schema/app';
|
||||
import z from "zod";
|
||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export function getStoreFolder ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return path.join(downlodDir, "store");
|
||||
}
|
||||
|
||||
async function getAllStoreEmulatorPackages ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators");
|
||||
const emulators = await fs.readdir(emulatorsBucket);
|
||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||
|
||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
||||
{
|
||||
if (e.error)
|
||||
{
|
||||
console.error(e.error);
|
||||
}
|
||||
return e.data;
|
||||
}).map(e => e.data!);
|
||||
|
||||
return emulatesParsed;
|
||||
}
|
||||
|
||||
async function buildSystems (emulator: EmulatorPackageType)
|
||||
{
|
||||
const systems = await Promise.all(emulator.systems.map(async system =>
|
||||
{
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
|
||||
});
|
||||
|
||||
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
|
||||
|
||||
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
|
||||
|
||||
return { id: system, name: esSystem?.fullname ?? system, icon: icon };
|
||||
}));
|
||||
|
||||
return systems;
|
||||
}
|
||||
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
|
||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||
import { Glob } from "bun";
|
||||
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
.get('/emulators', async ({ query }) =>
|
||||
|
|
@ -70,27 +28,10 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
.filter(e => e.os.includes(process.platform as any))
|
||||
.map(async (emulator) =>
|
||||
{
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulator?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = await buildSystems(emulator);
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||
const gameCounts = await Promise.all(systems.map(async (s) =>
|
||||
{
|
||||
const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) });
|
||||
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
|
||||
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
|
||||
if (romPlatform)
|
||||
{
|
||||
return romPlatform.rom_count;
|
||||
|
|
@ -101,13 +42,12 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
}));
|
||||
|
||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
||||
|
||||
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
|
||||
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
|
||||
}));
|
||||
|
||||
if (query.missing)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.validSource);
|
||||
}
|
||||
|
||||
if (query.orderBy === 'importance')
|
||||
|
|
@ -161,42 +101,65 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
||||
},
|
||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||
.get('/details/emulator/:id', async ({ params: { id } }) =>
|
||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
if (!emulatorPackage) return status("Not Found");
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage);
|
||||
|
||||
const execPaths = await findExecsByName(emulatorPackage.name);
|
||||
|
||||
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
|
||||
const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
|
||||
|
||||
const systems = await buildSystems(emulatorPackage);
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulatorPackage?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const validExec = execPaths.find(p => p.exists);
|
||||
const emulator: FrontEndEmulatorDetailed = {
|
||||
...emulatorPackage,
|
||||
name: emulatorPackage.name,
|
||||
description: emulatorPackage.description,
|
||||
systems,
|
||||
exists,
|
||||
status: {
|
||||
source: execPath?.type,
|
||||
location: execPath?.path
|
||||
},
|
||||
validSource: validExec,
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
|
||||
gameCount: 0
|
||||
gameCount: 0,
|
||||
homepage: emulatorPackage.homepage,
|
||||
downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
|
||||
{
|
||||
if (d.type === 'github' && d.path)
|
||||
{
|
||||
const release = await getOrCachedGithubRelease(d.path);
|
||||
const glob = new Glob(d.pattern);
|
||||
const download: FrontEndEmulatorDetailedDownload = {
|
||||
name: d.type,
|
||||
type: release.assets.find(a => glob.match(a.name))?.content_type
|
||||
};
|
||||
return download;
|
||||
};
|
||||
|
||||
return { name: d.type, type: "Unknown" };
|
||||
}) ?? []),
|
||||
logo: emulatorPackage.logo,
|
||||
sources: execPaths
|
||||
};
|
||||
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) });
|
||||
}, { params: z.object({ id: z.string() }) })
|
||||
.post('/install/emulator/:id/:source', async ({ params: { source, id } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
|
||||
{
|
||||
return status("Conflict", "Installation already running");
|
||||
}
|
||||
const job = new EmulatorDownloadJob(id, source);
|
||||
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
|
||||
})
|
||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
|
||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||
if (await fs.exists(storeEmulatorFolder))
|
||||
{
|
||||
fs.rm(storeEmulatorFolder, { recursive: true });
|
||||
return status("OK");
|
||||
}
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
|||
import { getDevices, getDevicesCurated } from "./drives";
|
||||
import getFolderSize from "get-folder-size";
|
||||
import si from 'systeminformation';
|
||||
import { getStoreFolder } from "./store/store";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,44 @@
|
|||
|
||||
import { JobStatus } from '@/shared/constants';
|
||||
import EventEmitter from 'node:events';
|
||||
import z, { ZodTypeAny } from 'zod';
|
||||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
public enqueue (id: string, job: IJob): Promise<void>
|
||||
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
||||
{
|
||||
this.disposeSafeguard();
|
||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||
const context = new JobContext(id, this.events, job);
|
||||
this.queue.push({ context });
|
||||
this.events?.emit('queued', { id: context.id, job: context });
|
||||
return this.processQueue();
|
||||
}
|
||||
|
||||
private processQueue (): Promise<void>
|
||||
private processQueue ()
|
||||
{
|
||||
if (!this.queue) return Promise.resolve();
|
||||
const top = this.queue.pop();
|
||||
if (top)
|
||||
|
||||
const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job }));
|
||||
|
||||
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
||||
|
||||
next.forEach(job =>
|
||||
{
|
||||
const promise = top.context.start();
|
||||
top.promise = promise;
|
||||
const index = this.queue.length;
|
||||
this.activeQueue.push(top);
|
||||
const promise = job.job.context.start();
|
||||
job.job.promise = promise;
|
||||
this.activeQueue.push(job.job);
|
||||
promise.finally(() =>
|
||||
{
|
||||
const index = this.activeQueue.indexOf(job.job);
|
||||
this.activeQueue.splice(index, 1);
|
||||
setTimeout(this.processQueue);
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
});
|
||||
});
|
||||
return promise;
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private disposeSafeguard ()
|
||||
|
|
@ -65,11 +69,16 @@ export class TaskQueue
|
|||
return job?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public findJob (id: string): IPublicJob | undefined
|
||||
|
||||
public findJob<const TData, const TState extends string, const T extends IJob<TData, TState>> (id: string, type: new (...args: any[]) => T): IPublicJob<TData, TState, T> | undefined
|
||||
{
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
if (job?.context.job instanceof type)
|
||||
{
|
||||
return job?.context;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
||||
{
|
||||
|
|
@ -99,12 +108,13 @@ export interface EventsList
|
|||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
queued: [e: BaseEvent];
|
||||
}
|
||||
|
||||
interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IPublicJob;
|
||||
job: IPublicJob<any, string, any>;
|
||||
}
|
||||
|
||||
interface ErrorEvent extends BaseEvent
|
||||
|
|
@ -128,37 +138,50 @@ interface CompletedEvent extends BaseEvent
|
|||
|
||||
}
|
||||
|
||||
export interface IJob
|
||||
export interface IJob<TData, TState extends string>
|
||||
{
|
||||
start (context: JobContext): Promise<any>;
|
||||
exposeData?(): any;
|
||||
group?: string;
|
||||
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
||||
exposeData?(): TData;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
||||
|
||||
export interface IPublicJob
|
||||
export interface IPublicJob<TData, TState extends string, T extends IJob<TData, TState>>
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: IJob;
|
||||
job: T;
|
||||
abort: (reason?: any) => void;
|
||||
}
|
||||
|
||||
export class JobContext implements IPublicJob
|
||||
type JobClass = new (...args: any[]) => IJob<any, any>;
|
||||
type JobClassWithStatics = JobClass & {
|
||||
id: string;
|
||||
dataSchema?: any;
|
||||
};
|
||||
export type JobContextFromClass<C extends JobClassWithStatics> =
|
||||
JobContext<
|
||||
InstanceType<C>,
|
||||
C extends { dataSchema: ZodTypeAny; }
|
||||
? z.infer<C['dataSchema']>
|
||||
: never,
|
||||
C['id']
|
||||
>;
|
||||
|
||||
export class JobContext<T extends IJob<TData, TState>, TData, TState extends string> implements IPublicJob<TData, TState, T>
|
||||
{
|
||||
private m_id: string;
|
||||
private m_progress: number = 0;
|
||||
private m_state?: string;
|
||||
private m_state?: TState;
|
||||
private running: boolean = false;
|
||||
private aborted: boolean = false;
|
||||
private completed: boolean = false;
|
||||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private readonly m_job: IJob;
|
||||
private readonly m_job: T;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: T)
|
||||
{
|
||||
this.m_id = id;
|
||||
this.m_job = job;
|
||||
|
|
@ -202,7 +225,7 @@ export class JobContext implements IPublicJob
|
|||
if (this.error) return 'error';
|
||||
if (this.aborted) return 'aborted';
|
||||
if (this.running) return 'running';
|
||||
return 'waiting';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
public get id () { return this.m_id; }
|
||||
|
|
@ -215,7 +238,11 @@ export class JobContext implements IPublicJob
|
|||
|
||||
public get state () { return this.m_state; }
|
||||
|
||||
public setProgress (progress: number, state?: string)
|
||||
/**
|
||||
* @param progress The 0 to 100 progress
|
||||
* @param state what type of progress is this. Is it really progress. I humanity even advancing.
|
||||
*/
|
||||
public setProgress (progress: number, state?: TState)
|
||||
{
|
||||
this.m_progress = progress;
|
||||
if (state)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { createInterface } from 'readline';
|
|||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; } | undefined;
|
||||
|
||||
if (!Bun.env.PUBLIC_ACCESS)
|
||||
if (!process.env.PUBLIC_ACCESS)
|
||||
{
|
||||
bunServer = RunBunServer();
|
||||
bunServer = await RunBunServer();
|
||||
}
|
||||
|
||||
async function cleanup ()
|
||||
|
|
@ -24,7 +24,7 @@ async function cleanup ()
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (Bun.env.HEADLESS)
|
||||
if (process.env.HEADLESS)
|
||||
{
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import staticPlugin from "@elysiajs/static";
|
|||
export function RunBunServer ()
|
||||
{
|
||||
console.log("Launching Server on port ", SERVER_PORT);
|
||||
return new Elysia()
|
||||
const server = new Elysia()
|
||||
.use(cors())
|
||||
.headers({
|
||||
'cross-origin-embedder-policy': 'credentialless',
|
||||
|
|
@ -28,33 +28,11 @@ export function RunBunServer ()
|
|||
assets: appPath("./dist"),
|
||||
prefix: "/",
|
||||
alwaysStatic: true
|
||||
})).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
||||
/*return Bun.serve({
|
||||
port: SERVER_PORT,
|
||||
hostname: host,
|
||||
routes: {
|
||||
"/": Bun.file(appPath("./dist/index.html")),
|
||||
// Serve a file by lazily loading it into memory
|
||||
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
|
||||
"/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")),
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
|
||||
JSON.stringify({
|
||||
name: appInfo.name,
|
||||
version: appInfo.version,
|
||||
debuggable: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
return new Promise<typeof server>((resolve) =>
|
||||
{
|
||||
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)}`)));
|
||||
},
|
||||
});*/
|
||||
server.onStart(() => resolve(server))
|
||||
.listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
||||
});
|
||||
}
|
||||
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;
|
||||
gameId: number;
|
||||
name: string;
|
||||
command: string;
|
||||
command: { command: string, startDir?: string; };
|
||||
};
|
||||
|
||||
interface ObjectConstructor
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { $ } from 'bun';
|
||||
import path from 'node:path';
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
export function checkRunning (pid: number)
|
||||
{
|
||||
|
|
@ -69,3 +71,43 @@ export async function openExternal (target: string)
|
|||
return $`open ${target}`.throws(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function hashFile (path: string, algorithm: "sha1" | "md5"): Promise<string>
|
||||
{
|
||||
return new Promise((resolve, reject) =>
|
||||
{
|
||||
const hash = createHash(algorithm);
|
||||
const stream = createReadStream(path);
|
||||
|
||||
stream.on("data", (data) => hash.update(data));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export class SeededRandom
|
||||
{
|
||||
seed: number;
|
||||
|
||||
constructor(seed?: number)
|
||||
{
|
||||
this.seed = seed ?? new Date().getTime();
|
||||
}
|
||||
|
||||
next ()
|
||||
{
|
||||
var x = Math.sin(this.seed++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
}
|
||||
|
||||
export function shuffleInPlace (array: any[], startSeed?: number)
|
||||
{
|
||||
const random = new SeededRandom(startSeed);
|
||||
|
||||
for (let i = array.length - 1; i > 0; i--)
|
||||
{
|
||||
const j = Math.floor(random.next() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
222
src/bun/utils/downloader.ts
Normal file
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;
|
||||
onBlur?: (id: string) => void;
|
||||
clickFocuses?: boolean;
|
||||
previewClassName?: string;
|
||||
}
|
||||
|
||||
export default function CardElement (data: GameCardParams & InteractParams)
|
||||
|
|
@ -53,7 +54,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
role="button"
|
||||
ref={ref}
|
||||
style={{
|
||||
scrollSnapAlign: "center"
|
||||
scrollSnapAlign: isPointer ? "center" : "none"
|
||||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
|
||||
|
|
@ -74,7 +75,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
classNames({ "h-full": typeof data.preview === "string" })
|
||||
)}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
<img draggable={false} className={classNames("object-cover w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
) : (
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import { RPC_URL } from "@/shared/constants";
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import queries from "../scripts/queries";
|
||||
import { getCollectionsQuery } from "@queries/romm";
|
||||
|
||||
export default function CollectionList (data: {
|
||||
id: string,
|
||||
|
|
@ -17,12 +15,11 @@ export default function CollectionList (data: {
|
|||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
|
||||
const { data: collections } = useSuspenseQuery(getCollectionsQuery());
|
||||
|
||||
const handleDefaultSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
navigate({ to: `/collection/${id}` });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -36,7 +33,7 @@ export default function CollectionList (data: {
|
|||
id: String(g.id),
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
subtitle: g.user__username,
|
||||
subtitle: g.owner_username,
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
|
|||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
import { Router } from '..';
|
||||
import { HandleGoBack } from '../scripts/utils';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
|
|
@ -30,7 +32,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
const handleScroll: GameCardFocusHandler = (id, node, details) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX, useContext, useEffect } from "react";
|
||||
import { JSX, useContext, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { X } from "lucide-react";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
|
|
@ -67,21 +67,61 @@ export interface DialogEntry
|
|||
shortcuts?: Shortcut[];
|
||||
}
|
||||
|
||||
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; })
|
||||
{
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
|
||||
const dialog = <ContextDialog id={id} open={open} close={() =>
|
||||
{
|
||||
setOpen(false);
|
||||
data.onClose?.();
|
||||
}} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}>
|
||||
{data.content}
|
||||
</ContextDialog>;
|
||||
return {
|
||||
dialog,
|
||||
open,
|
||||
setOpen: (value: boolean, sourceFocusKey?: string) =>
|
||||
{
|
||||
if (value === open) return;
|
||||
if (value)
|
||||
{
|
||||
setOpen(true);
|
||||
setSourceFocusKey(sourceFocusKey);
|
||||
} else
|
||||
{
|
||||
setOpen(false);
|
||||
if (sourceFocusKey)
|
||||
{
|
||||
setFocus(sourceFocusKey);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextDialog (data: {
|
||||
id: string,
|
||||
children: any | any[],
|
||||
open: boolean,
|
||||
close: () => void;
|
||||
close: (open: boolean) => void;
|
||||
className?: string;
|
||||
preferredChildFocusKey?: string;
|
||||
sourceFocusKey?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusable: data.open,
|
||||
focusKey: `${data.id}-context-dialog`,
|
||||
isFocusBoundary: true,
|
||||
saveLastFocusedChild: !data.preferredChildFocusKey,
|
||||
preferredChildFocusKey: data.preferredChildFocusKey
|
||||
});
|
||||
const handleClose = () =>
|
||||
{
|
||||
data.close(false);
|
||||
};
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
|
|
@ -93,22 +133,16 @@ export function ContextDialog (data: {
|
|||
useShortcuts(focusKey, () => data.open ? [{
|
||||
label: "Close",
|
||||
button: GamePadButtonCode.B,
|
||||
action: () =>
|
||||
{
|
||||
data.close();
|
||||
}
|
||||
action: handleClose
|
||||
}] : [], [data.open]);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
classNames({ "opacity-0": !data.open }))
|
||||
}
|
||||
onClick={() =>
|
||||
{
|
||||
if (data.open) data.close();
|
||||
}}>
|
||||
onClick={handleClose}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
|
|||
import SvgIcon from "./SvgIcon";
|
||||
import { Button } from "./options/Button";
|
||||
import toast from "react-hot-toast";
|
||||
import queries from "../scripts/queries";
|
||||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
|
||||
|
||||
function List (data: {
|
||||
id: string,
|
||||
|
|
@ -113,7 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
|||
const { refetchFiles } = useContext(FilePickerContext);
|
||||
const [name, setName] = useState<string | undefined>();
|
||||
const createMutation = useMutation({
|
||||
...queries.system.createFolderMutation(data.id),
|
||||
...createFolderMutation(data.id),
|
||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
|
|
@ -228,8 +228,8 @@ export default function FilePicker (data: {
|
|||
{
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery);
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
|
||||
|
||||
const fullPath = files ? path.join(files.parentPath, files.name) : '';
|
||||
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
setFocus,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import SvgIcon from "./SvgIcon";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect } from "react";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
function FilterCat (
|
||||
data: {
|
||||
id: string;
|
||||
children?: any;
|
||||
active: boolean;
|
||||
hasFocusedPeer: boolean;
|
||||
} & FilterOption & FocusParams,
|
||||
)
|
||||
{
|
||||
|
|
@ -26,9 +28,10 @@ function FilterCat (
|
|||
aria-selected={data.active}
|
||||
ref={ref}
|
||||
onClick={focusSelf}
|
||||
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
|
||||
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"}
|
||||
>
|
||||
{data.children ?? data.label}
|
||||
{data.icon ? <><div className="sm:portrait:px-2">{data.icon}</div><div className="sm:portrait:hidden md:inline">{data.children ?? data.label}</div></> : <div>{data.children ?? data.label}</div>}
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +42,8 @@ export function FilterUI (data: {
|
|||
setSelected: (id: string) => void;
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
rootFocusKey?: string;
|
||||
showShortcuts?: boolean;
|
||||
})
|
||||
{
|
||||
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
|
||||
|
|
@ -50,29 +55,72 @@ export function FilterUI (data: {
|
|||
trackChildren: true
|
||||
});
|
||||
|
||||
if (data.rootFocusKey)
|
||||
{
|
||||
useShortcuts(data.rootFocusKey, () => [
|
||||
{
|
||||
action: (e) =>
|
||||
{
|
||||
const filterKeys = Object.keys(data.options);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f].selected));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
if (!data.options[newFilter].selected)
|
||||
{
|
||||
data.setSelected(newFilter);
|
||||
}
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: (e) =>
|
||||
{
|
||||
const filterKeys = Object.keys(data.options);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f as any].selected));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
if (!data.options[newFilter].selected)
|
||||
data.setSelected(newFilter);
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [data.options]);
|
||||
}
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (hasFocusedChild)
|
||||
{
|
||||
setFocus(`${data.id}-${defaultFocus}`);
|
||||
}
|
||||
}, [hasFocusedChild, defaultFocus, data.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={data.containerClassName}
|
||||
style={{ viewTransitionName: `filter-${data.id}` }}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
|
||||
<li className=" flex px-4 items-center justify-center rounded-full">
|
||||
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||
</li>
|
||||
</li>}
|
||||
{Object.entries(data.options)?.map(([id, option]) => (
|
||||
<FilterCat
|
||||
hasFocusedPeer={hasFocusedChild}
|
||||
id={`${data.id}-${id}`}
|
||||
key={id}
|
||||
onFocus={() => data.setSelected(id)}
|
||||
onFocus={() =>
|
||||
{
|
||||
if (!option.selected)
|
||||
data.setSelected(id);
|
||||
}}
|
||||
active={option.selected}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<li className="flex px-4 items-center justify-center rounded-full">
|
||||
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className="flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_r1_outline" />
|
||||
</li>
|
||||
</li>}
|
||||
</ul>
|
||||
</FocusContext.Provider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export default function FocusDots (data: {
|
|||
scrollElement?: RefObject<HTMLElement | null>;
|
||||
})
|
||||
{
|
||||
|
||||
const focusedKey = useGlobalFocus();
|
||||
let elements = useMemo(() =>
|
||||
{
|
||||
|
|
@ -62,7 +61,7 @@ export default function FocusDots (data: {
|
|||
|
||||
return childrenArray.map((c, i) =>
|
||||
{
|
||||
return <ScrollDot parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
return <ScrollDot key={i} parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
});
|
||||
} else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
||||
import CardElement from "./CardElement";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { Router } from "..";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
|
||||
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
|
||||
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams)
|
||||
{
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
|
||||
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||
};
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||
|
|
@ -27,7 +24,26 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
previewUrl.searchParams.set('width', "640");
|
||||
|
||||
const badges: JSX.Element[] = [];
|
||||
if (data.game.id.source === 'local')
|
||||
|
||||
if (data.showSource)
|
||||
{
|
||||
switch (data.game.id.source)
|
||||
{
|
||||
case "local":
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
case "romm":
|
||||
badges.push(<img className="sm:size-4 md:size-8 m-1 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`} />);
|
||||
break;
|
||||
case "store":
|
||||
badges.push(<Store className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
default:
|
||||
badges.push(<FileQuestion className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
}
|
||||
|
||||
} else if (data.game.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
}
|
||||
|
|
@ -39,7 +55,9 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
preview={previewUrl.href}
|
||||
title={data.game.name ?? ""}
|
||||
subtitle={subtitle}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
||||
className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""}
|
||||
previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""}
|
||||
index={data.index}
|
||||
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
||||
/>;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
|||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||
import { JSX, useContext } from "react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import queries from "../scripts/queries";
|
||||
import { allGamesQuery } from "@queries/romm";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -25,7 +24,7 @@ export interface GameListParams
|
|||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
|
||||
const games = useSuspenseQuery(allGamesQuery(data.filters));
|
||||
const navigator = useNavigate();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||
|
|
@ -51,8 +50,7 @@ export function GameList (data: GameListParams)
|
|||
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
SaveSource('details', { search: { focus: g.slug ?? `game-${g.id}` } });
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -74,6 +72,7 @@ export function GameList (data: GameListParams)
|
|||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||
}
|
||||
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "16");
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import { RoundButton } from "./RoundButton";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { Router } from "..";
|
||||
import { useStickyDataAttr } from "../scripts/utils";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
|
|
@ -240,8 +240,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
|||
],
|
||||
action: () =>
|
||||
{
|
||||
SaveSource('settings');
|
||||
Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
type: 'secondary'
|
||||
|
|
@ -284,15 +283,19 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
|||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: {
|
||||
interface HeaderUIParams
|
||||
{
|
||||
buttons?: HeaderButton[];
|
||||
accounts?: HeaderAccount[];
|
||||
buttonElements?: JSX.Element[] | JSX.Element;
|
||||
title?: JSX.Element;
|
||||
preferredChildFocusKey?: string;
|
||||
})
|
||||
focusable?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: HeaderUIParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<header
|
||||
|
|
@ -307,3 +310,18 @@ export function HeaderUI (data: {
|
|||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
|
||||
{
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
useStickyDataAttr(headerRef, sentinelRef, data.ref, setIsStuck);
|
||||
|
||||
return <>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
|
||||
<HeaderUI focusable={!isStuck} {...data} />
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
import { useIntersectionObserver } from "usehooks-ts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Notification, RPC_URL } from "@/shared/constants";
|
||||
import { useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import toast, { ToastOptions } from "react-hot-toast";
|
||||
|
||||
export default function Notifications (data: {})
|
||||
{
|
||||
|
|
@ -10,15 +10,16 @@ export default function Notifications (data: {})
|
|||
es.addEventListener('notification', (e) =>
|
||||
{
|
||||
const notification = JSON.parse(e.data) as Notification;
|
||||
const options: ToastOptions = { removeDelay: notification.duration };
|
||||
if (notification.type === 'error')
|
||||
{
|
||||
toast.error(notification.message);
|
||||
toast.error(notification.message, options);
|
||||
} else if (notification.type === 'success')
|
||||
{
|
||||
toast.success(notification.message);
|
||||
toast.success(notification.message, options);
|
||||
} else
|
||||
{
|
||||
toast.custom(notification.message);
|
||||
toast.custom(notification.message, options);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router";
|
|||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
|
|
@ -37,8 +36,7 @@ export function PlatformsList (data: {
|
|||
|
||||
const handleDefaultSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
navigate({ to: `/platform/${source}/${id}` });
|
||||
};
|
||||
|
||||
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import FocusDots from "./FocusDots";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
||||
import { Fullscreen } from "lucide-react";
|
||||
import Carousel from "./Carousel";
|
||||
import { ContextDialog } from "./ContextDialog";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||
{
|
||||
|
|
@ -26,7 +27,43 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
|||
</div>;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
|
||||
function Preview (data: { id: string; screenshots?: string[]; preview: number; setPreview: Dispatch<SetStateAction<number | undefined>>; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: data.id });
|
||||
|
||||
useShortcuts(focusKey, () => [
|
||||
{
|
||||
button: GamePadButtonCode.Left,
|
||||
label: "Left",
|
||||
action: () =>
|
||||
{
|
||||
if (data.preview === undefined || !data.screenshots) return;
|
||||
data.setPreview(p =>
|
||||
{
|
||||
if (!data.screenshots) return p;
|
||||
return (data.screenshots.length + (p ?? 0) - 1) % data.screenshots.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
button: GamePadButtonCode.Right,
|
||||
label: "Right",
|
||||
action: () =>
|
||||
{
|
||||
if (data.preview === undefined || !data.screenshots) return;
|
||||
data.setPreview(p =>
|
||||
{
|
||||
if (!data.screenshots) return p;
|
||||
return (p ?? 0 + 1) % data.screenshots.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
], [data.preview, focusKey, data.screenshots?.length ?? 0]);
|
||||
|
||||
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)
|
||||
{
|
||||
const [preview, setPreview] = useState<number | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -41,9 +78,10 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
|
||||
useEffect(() =>
|
||||
{
|
||||
if ((focused || hasFocusedChild) && scrollRef.current)
|
||||
if ((focused || hasFocusedChild) && scrollRef.current && data.screenshots)
|
||||
{
|
||||
const closest = findClosestElementToCenter(scrollRef.current);
|
||||
if (!closest) return;
|
||||
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
||||
setFocus(`screenshot-${closestIndex}`);
|
||||
}
|
||||
|
|
@ -54,6 +92,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
const center = element.scrollLeft + element.clientWidth / 2;
|
||||
|
||||
const children = Array.from(element.children) as HTMLElement[];
|
||||
if (children.length <= 0) return undefined;
|
||||
|
||||
// find child closest to center
|
||||
return children.reduce((closest, child) =>
|
||||
|
|
@ -78,7 +117,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
||||
{
|
||||
const current = findClosestElementToCenter(element);
|
||||
|
||||
if (!current) return;
|
||||
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
||||
if (!next) return;
|
||||
|
||||
|
|
@ -89,42 +128,21 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
});
|
||||
};
|
||||
|
||||
useShortcuts(`screenshots-context-dialog`, () => [
|
||||
{
|
||||
button: GamePadButtonCode.Left,
|
||||
label: "Left",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((data.screenshots.length + preview - 1) % data.screenshots.length);
|
||||
}
|
||||
},
|
||||
{
|
||||
button: GamePadButtonCode.Right,
|
||||
label: "Right",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((preview + 1) % data.screenshots.length);
|
||||
}
|
||||
}
|
||||
], [preview, focusKey]);
|
||||
|
||||
useDragScroll(scrollRef);
|
||||
|
||||
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
|
||||
return <div ref={ref} className={twMerge("flex flex-col w-full z-0 min-h-0", data.className)}>
|
||||
<FocusContext value={focusKey}>
|
||||
<Carousel scrollHandler={handleScroll} scrollRef={scrollRef} rootClassName="h-full" className="flex gap-6 px-16 py-2 overflow-x-scroll no-scrollbar justify-center-safe h-full" >
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />)}
|
||||
{data.screenshots?.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />) ?? <div className="skeleton w-32 h-32"></div>}
|
||||
</Carousel>
|
||||
<FocusDots scrollElement={scrollRef} />
|
||||
</FocusContext>
|
||||
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
||||
{
|
||||
setFocus(`screenshot-${preview}`);
|
||||
setFocus(`screenshot-${preview}`, { instant: true });
|
||||
setPreview(undefined);
|
||||
}} open={true}>
|
||||
<img draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots[preview]}`} loading="lazy" />
|
||||
<Preview id="screenshot-preview" screenshots={data.screenshots} preview={preview} setPreview={setPreview} />
|
||||
</ContextDialog>}
|
||||
</div>;
|
||||
}
|
||||
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 { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { changeDownloadsMutation } from "@queries/settings";
|
||||
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setSettingMutation = useMutation({
|
||||
...queries.settings.changeDownloadsMutation,
|
||||
...changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
|
|||
import { ContextDialog } from "../ContextDialog";
|
||||
import FilePicker from "../FilePicker";
|
||||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -33,7 +33,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setMutation = useMutation({
|
||||
...queries.settings.setSettingMutation(data.id),
|
||||
...setSettingMutation(data.id),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
@ -63,7 +63,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
})
|
||||
{
|
||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||
const { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
|
||||
const changed = defaultValue !== data.localValue;
|
||||
|
||||
useEffect(() =>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SettingsType } from "../../../shared/constants";
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -20,8 +20,8 @@ export function SettingsOption (data: {
|
|||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
|
||||
useQuery(getSettingQuery(data.id));
|
||||
const setMutation = useMutation(setSettingMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function EmulatorsSection (data: {
|
|||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}} />
|
||||
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
|
||||
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
|
||||
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
|
||||
</Carousel>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,58 @@
|
|||
import { useRef } from "react";
|
||||
import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Gamepad2, Star } from "lucide-react";
|
||||
import { useDragScroll } from "@/mainview/scripts/utils";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||
import FocusDots from "../FocusDots";
|
||||
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
||||
import FrontEndGameCard from "../FrontEndGameCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import Carousel from "../Carousel";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
||||
export function GamesSection (data: {
|
||||
games?: FrontEndGameType[];
|
||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||
className?: string;
|
||||
showSources?: boolean;
|
||||
ref?: Ref<any>;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
const { ref, focusKey, focused, focusSelf } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.GAME_SECTION,
|
||||
trackChildren: true,
|
||||
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
|
||||
});
|
||||
const containerRef = useRef(null);
|
||||
useDragScroll(containerRef);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focused)
|
||||
focusSelf();
|
||||
}, [!!data.games]);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<section ref={ref} className="px-6 py-3 select-none">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Featured Games
|
||||
</h2>
|
||||
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
|
||||
</div>
|
||||
<section ref={(r) =>
|
||||
{
|
||||
ref.current = r;
|
||||
if (data.ref instanceof Function) data.ref(r);
|
||||
else if (data.ref) data.ref.current = r;
|
||||
}} className={twMerge("select-none", data.className)}>
|
||||
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
|
||||
{games?.map((g, i) => <FrontEndGameCard
|
||||
{data.games?.map((g, i) => <FrontEndGameCard
|
||||
showSource={data.showSources}
|
||||
key={g.id.id}
|
||||
game={g}
|
||||
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
|
||||
onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))}
|
||||
onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })}
|
||||
index={i} />) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full" />)}
|
||||
</Carousel>
|
||||
</section>
|
||||
<FocusDots elements={games?.map(e => FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} />
|
||||
<FocusDots elements={data.games?.map(e => FOCUS_KEYS.GAME_CARD(e.id)) ?? []} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
import { storeGetStatsQuery } from "@queries/store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export function StatsSection ({
|
|||
}: StatsSectionProps)
|
||||
{
|
||||
|
||||
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
|
||||
const { data: stats } = useQuery(storeGetStatsQuery);
|
||||
|
||||
return (
|
||||
<section className="px-6 pt-3 pb-4">
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@ import { Button } from "../options/Button";
|
|||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { ChevronRight, EllipsisVertical, HardDrive } from "lucide-react";
|
||||
import { ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Store } from "lucide-react";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
|
||||
import { JSX } from "react";
|
||||
|
||||
export const emulatorStatusIcons: Record<string, JSX.Element> = {
|
||||
store: <Store />,
|
||||
custom: <FileQuestion />,
|
||||
flatpak: FlatpackIcon,
|
||||
winget: <Package />,
|
||||
scoop: <IceCream2 />
|
||||
};
|
||||
|
||||
export function StoreEmulatorCard (data: {
|
||||
id: string;
|
||||
|
|
@ -35,7 +45,7 @@ export function StoreEmulatorCard (data: {
|
|||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-installed={data.emulator.exists ? true : undefined}
|
||||
data-installed={!!data.emulator.validSource}
|
||||
onClick={isTouch ? handleSelect : undefined}
|
||||
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
|
||||
>
|
||||
|
|
@ -44,14 +54,14 @@ export function StoreEmulatorCard (data: {
|
|||
<div className="flex gap-2">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
data-installed={data.emulator.exists}
|
||||
data-installed={!!data.emulator.validSource}
|
||||
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
|
||||
>
|
||||
<img draggable={false} src={data.emulator.logo}></img>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||
<p data-installed={!!data.emulator.validSource} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||
{
|
||||
|
|
@ -66,10 +76,12 @@ export function StoreEmulatorCard (data: {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
||||
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
|
||||
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
|
||||
{!!data.emulator.validSource && <div className="tooltip" data-tip={data.emulator.validSource.type}>
|
||||
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content">
|
||||
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
|
||||
</div>
|
||||
</div>}
|
||||
{<div className="tooltip" data-tip="Game Count">
|
||||
{data.emulator.gameCount > 0 && <div className="tooltip" data-tip="Game Count">
|
||||
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
|
||||
</div>}
|
||||
{isMouse && <>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ window.addEventListener('message', (e) =>
|
|||
|
||||
});
|
||||
|
||||
window.EJS_threads = true;
|
||||
window.EJS_player = "#game";
|
||||
window.EJS_lightgun = false;
|
||||
window.EJS_startOnLoaded = true;
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
|||
]);
|
||||
|
||||
// Store basePath resolved from Vite config
|
||||
const BASE_PATH = "./";
|
||||
const BASE_PATH = "/";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -393,6 +393,17 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(slide-up) {
|
||||
|
||||
&::view-transition-old(root) {
|
||||
animation: fade-out 300ms ease-in forwards;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
animation: slide-up 300ms ease-in-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(zoom-in) {
|
||||
|
||||
&::view-transition-old(root) {
|
||||
|
|
@ -449,6 +460,18 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(10vh);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoom-in-fade-in {
|
||||
from {
|
||||
scale: 105%;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import "./scripts/spatialNavigation";
|
|||
import NotFound from "./components/NotFound";
|
||||
import Error from "./components/Error";
|
||||
import serviceWorker from './scripts/serviceWorker?worker&url';
|
||||
import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
||||
if ('serviceWorker' in navigator)
|
||||
{
|
||||
|
|
@ -44,10 +45,42 @@ export const Router = createRouter({
|
|||
history: hashHistory,
|
||||
defaultPreload: "intent",
|
||||
context: { queryClient },
|
||||
scrollRestoration: false,
|
||||
scrollRestoration: true,
|
||||
defaultNotFoundComponent: NotFound,
|
||||
defaultPendingMs: 300,
|
||||
defaultErrorComponent: Error
|
||||
defaultErrorComponent: Error,
|
||||
defaultViewTransition: {
|
||||
types ({ fromLocation, toLocation })
|
||||
{
|
||||
let direction = 'in';
|
||||
if (fromLocation)
|
||||
{
|
||||
const fromIndex = fromLocation.state.__TSR_index;
|
||||
const toIndex = toLocation.state.__TSR_index;
|
||||
|
||||
direction = fromIndex > toIndex ? 'in' : 'out';
|
||||
}
|
||||
|
||||
return [`zoom-${direction}`];
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const focusMap = new Map<number, string>();
|
||||
|
||||
Router.history.subscribe((op) =>
|
||||
{
|
||||
if (op.action.type === 'PUSH')
|
||||
{
|
||||
focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey());
|
||||
} else if (op.action.type === 'BACK')
|
||||
{
|
||||
if (focusMap.has(op.location.state.__TSR_index))
|
||||
{
|
||||
setFocus(focusMap.get(op.location.state.__TSR_index)!);
|
||||
focusMap.delete(op.location.state.__TSR_index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { DefaultRommStaleTime } from '@shared/constants';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
import queries from '../scripts/queries';
|
||||
import { getCollectionQuery } from '@queries/romm';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -18,7 +18,7 @@ export const Route = createFileRoute('/collection/$id')({
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: collection } = useQuery(queries.romm.getCollectionQuery(Number(id)));
|
||||
const { data: collection } = useQuery(getCollectionQuery(Number(id)));
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import useActiveControl from '../scripts/gamepads';
|
|||
import { twMerge } from 'tailwind-merge';
|
||||
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
||||
import { RoundButton } from '../components/RoundButton';
|
||||
import queries from '../scripts/queries';
|
||||
import { gameQuery } from '@queries/romm';
|
||||
|
||||
export const Route = createFileRoute('/embedded/$source/$id')({
|
||||
component: RouteComponent,
|
||||
loader: async (ctx) =>
|
||||
{
|
||||
const data = await ctx.context.queryClient.fetchQuery(queries.romm.gameQuery(ctx.params.source, ctx.params.id));
|
||||
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
|
||||
return { data };
|
||||
},
|
||||
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
||||
|
|
@ -133,7 +133,7 @@ function RouteComponent ()
|
|||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true });
|
||||
}
|
||||
|
||||
useEventListener('message', e =>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,53 @@
|
|||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { CommandEntry, RPC_URL } from "@shared/constants";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { Clock, CloudDownload, Download, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
|
||||
import { Calendar, Clock, CloudDownload, Download, EllipsisVertical, Folder, Gamepad2, HardDrive, Image, Info, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
|
||||
import { HeaderUI } from "../../components/Header";
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
|
||||
import { useFocusEventListener } from "../../scripts/spatialNavigation";
|
||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
import { rommApi } from "../../scripts/clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Router } from "../..";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
||||
import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog";
|
||||
import Shortcuts from "../../components/Shortcuts";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { FilterUI } from "@/mainview/components/Filters";
|
||||
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
||||
import { useIntersectionObserver, useLocalStorage } from "usehooks-ts";
|
||||
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import z from "zod";
|
||||
import Achievements from "@/mainview/components/game/Achievements";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import { GameDetailsContext } from "@/mainview/scripts/contexts";
|
||||
import { rommApi } from "@/mainview/scripts/clientApi";
|
||||
import { deleteGameMutation, gameQuery, gamesRecommendedBasedOnGameQuery, installMutation, playMutation } from "@queries/romm";
|
||||
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: async ({ params, context }) =>
|
||||
{
|
||||
const data = await context.queryClient.fetchQuery(queries.romm.gameQuery(params.source, params.id));
|
||||
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
|
||||
return { data };
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending,
|
||||
errorComponent: Error
|
||||
errorComponent: Error,
|
||||
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
||||
});
|
||||
|
||||
function useDetailsSection ()
|
||||
{
|
||||
return useLocalStorage('details-section', 'screenshots');
|
||||
}
|
||||
|
||||
function Error (data: ErrorComponentProps)
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||
|
|
@ -51,7 +67,7 @@ function Error (data: ErrorComponentProps)
|
|||
<HeaderUI />
|
||||
</div>
|
||||
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
|
||||
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {JSON.stringify(data.error, null, 3)}</div>
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
|
||||
|
|
@ -139,35 +155,52 @@ function GameDetailsUIPending ()
|
|||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
function MoreDetails (data: {})
|
||||
{
|
||||
const { to, search } = PopSource('details');
|
||||
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
|
||||
const { data: game } = Route.useLoaderData();
|
||||
const [details] = useDetailsSection();
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||
focusKey: "game-more-details-section",
|
||||
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||
trackChildren: true
|
||||
});
|
||||
|
||||
return <div ref={ref} className="scroll-mt-[15vh]">
|
||||
<FocusContext value={focusKey}>
|
||||
<Divider rootFocusKey={focusKey} showShortcuts={hasFocusedChild} />
|
||||
<div className="bg-base-200 py-12 min-h-[80vh]">
|
||||
<div key={details} className="h-full animate-slide-up">
|
||||
{details === 'screenshots' && <div className="h-[60vh]"><Screenshots screenshots={game.paths_screenshots} /></div>}
|
||||
{details === 'stats' && <Stats />}
|
||||
{details === 'achievements' && <Achievements game={game} />}
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>; })
|
||||
{
|
||||
const { data: game } = Route.useLoaderData();
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
||||
},
|
||||
focusKey: 'main-details',
|
||||
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
});
|
||||
|
||||
const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover ?? ''}`);
|
||||
const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${game?.path_platform_cover ?? ''}`);
|
||||
platformCoverImg.searchParams.set("width", "64");
|
||||
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
|
||||
const gameCoverImg = game?.path_cover ? `${RPC_URL(__HOST__)}${game?.path_cover}` : undefined;
|
||||
|
||||
let fileSizeIcon: JSX.Element | undefined;
|
||||
if (!data.game)
|
||||
if (!game)
|
||||
{
|
||||
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
||||
} else if (data.game.missing)
|
||||
} else if (game.missing)
|
||||
{
|
||||
fileSizeIcon = <TriangleAlert />;
|
||||
} else if (data.game.local)
|
||||
} else if (game.local)
|
||||
{
|
||||
fileSizeIcon = <HardDrive />;
|
||||
} else
|
||||
|
|
@ -186,23 +219,23 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
</div>
|
||||
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
||||
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
||||
<Detail icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</Detail>
|
||||
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
|
||||
<div className={classNames({ "text-error": data.game.missing })}>
|
||||
<div className="tooltip" data-tip={data.game.path_fs}>
|
||||
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
|
||||
<Detail icon={<Clock />} >{game?.last_played ? new Date(game.last_played).toDateString() : "Never"}</Detail>
|
||||
{!!game && (game.fs_size_bytes !== null || game.missing) &&
|
||||
<div className={classNames({ "text-error": game.missing })}>
|
||||
<div className="tooltip" data-tip={game.path_fs}>
|
||||
<Detail icon={fileSizeIcon} >{game.missing ? 'Missing' : prettyBytes(game.fs_size_bytes!)}</Detail>
|
||||
</div>
|
||||
</div>}
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg.href}></img>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg.href}></img>} >{game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
|
||||
<Detail icon={
|
||||
<Store />
|
||||
} >
|
||||
{data.game?.source ?? data.game?.id.source}
|
||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
{game?.source ?? game?.id.source}
|
||||
{game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
{game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
|
|
@ -211,107 +244,90 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>}
|
||||
</div>
|
||||
{!!data.game && <ActionButtons key="actions" game={data.game} />}
|
||||
{!!game && <ActionButtons key="actions" />}
|
||||
</div>
|
||||
</section>
|
||||
</FocusContext>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
|
||||
function AchievementsInfo (data: InteractParams)
|
||||
{
|
||||
if (!data.game.achievements)
|
||||
const { data: game } = Route.useLoaderData();
|
||||
if (!game.achievements)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
|
||||
<div className="flex flex-col gap-2 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
return <ActionButton key="achievements" square tooltip="Achievements" type="base" className="sm:rounded-2xl md:rounded-3xl" id="achievements" onAction={data.onAction} >
|
||||
<div className="flex flex-col sm:gap-0 md:gap-2 items-center sm:text-xl md:text-2xl sm:px-4 sm:py-2 md:p-0">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<Trophy />
|
||||
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
|
||||
{`${game.achievements.unlocked}/${game.achievements.total}`}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
|
||||
<progress className="progress progress-secondary w-full" value={game.achievements.unlocked / game.achievements.total} max="1"></progress>
|
||||
</div>
|
||||
</ActionButton>;
|
||||
}
|
||||
|
||||
function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||
function MainActions ()
|
||||
{
|
||||
const { data } = Route.useLoaderData();
|
||||
const { source, id } = Route.useParams();
|
||||
const installMutation = useMutation({
|
||||
mutationKey: ['install'],
|
||||
mutationFn: async () =>
|
||||
const installMut = useMutation(installMutation(source, id));
|
||||
const playMut = useMutation({
|
||||
...playMutation, onError (error)
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const playMutation = useMutation({
|
||||
mutationKey: ['play'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
|
||||
if (error)
|
||||
{
|
||||
if (error.value.message)
|
||||
{
|
||||
toast.error(error.value.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
}
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const ws = useRef<{ send: (data: string) => void; }>(undefined);
|
||||
const [progress, setProgress] = useState<number | undefined>(undefined);
|
||||
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
|
||||
const [preferredCommand, setPreferredCommand] = useLocalStorage<string | number | undefined>(`${data.source ?? data.id.source}-${data.source_id ?? data.id.id}-preferred-command`, undefined);
|
||||
const queryClient = useQueryClient();
|
||||
const validCommands = commands ? commands.filter(c => c.valid) : [];
|
||||
const validDefaultCommand = commands?.find(c =>
|
||||
{
|
||||
if (!c.valid) return false;
|
||||
if (preferredCommand && c.id !== preferredCommand) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`);
|
||||
const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
|
||||
ws.current = sub.ws;
|
||||
|
||||
es.onmessage = ({ data }) =>
|
||||
sub.subscribe((e) =>
|
||||
{
|
||||
const stats = JSON.parse(data) as GameInstallProgress;
|
||||
setProgress(stats.progress);
|
||||
setStatus(stats.status);
|
||||
setDetails(stats.details);
|
||||
setCommands(stats.commands);
|
||||
setError(stats.error);
|
||||
};
|
||||
setStatus(e.data.status);
|
||||
setProgress((e.data as any).progress);
|
||||
setDetails((e.data as any).details);
|
||||
setCommands((e.data as any).commands);
|
||||
|
||||
es.addEventListener('refresh', () =>
|
||||
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) =>
|
||||
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true });
|
||||
} else if (e.data.status === 'error')
|
||||
{
|
||||
if ((e as any).data)
|
||||
{
|
||||
const stats = JSON.parse((e as any).data) as GameInstallProgress;
|
||||
toast.error(stats.error);
|
||||
setError(stats.error);
|
||||
const errorMessage = getErrorMessage(e.data.error);
|
||||
if (!errorMessage) return;
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = (event) =>
|
||||
return () =>
|
||||
{
|
||||
const error = (event as any).data?.error;
|
||||
if (error)
|
||||
{
|
||||
toast.error(error);
|
||||
setError(error);
|
||||
}
|
||||
sub.close();
|
||||
ws.current = undefined;
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, [data.game.id]);
|
||||
}, [data.id]);
|
||||
|
||||
let progressIcon: JSX.Element | undefined = undefined;
|
||||
switch (status)
|
||||
|
|
@ -319,29 +335,51 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
case 'download':
|
||||
progressIcon = <Download />;
|
||||
break;
|
||||
case 'queued':
|
||||
progressIcon = <Clock />;
|
||||
break;
|
||||
case 'extract':
|
||||
progressIcon = <PackageOpen />;
|
||||
break;
|
||||
}
|
||||
|
||||
let mainButton: JSX.Element | undefined = undefined;
|
||||
if (status === 'installed')
|
||||
const showProgress = progress !== null && !!progressIcon;
|
||||
useEffect(() =>
|
||||
{
|
||||
mainButton = <ActionButton onAction={() =>
|
||||
if (showProgress) return;
|
||||
showInstallOptions(false);
|
||||
}, [showProgress]);
|
||||
|
||||
const handlePlay = (cmd?: CommandEntry) =>
|
||||
{
|
||||
const firstValid = commands?.find(c => c.valid);
|
||||
if (firstValid?.emulator === 'emulatorjs')
|
||||
if (!cmd) return;
|
||||
if (cmd.emulator === 'EMULATORJS')
|
||||
{
|
||||
const params = new URLSearchParams(firstValid.command);
|
||||
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
|
||||
const params = new URLSearchParams(cmd.command);
|
||||
Router.navigate({ to: '/embedded/$source/$id', params: { source, id }, search: Object.fromEntries(params.entries()), replace: true });
|
||||
} else
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
|
||||
let mainButton: any | undefined = undefined;
|
||||
if (status === 'installed')
|
||||
{
|
||||
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
||||
key="primary"
|
||||
type='primary'
|
||||
id="mainAction"
|
||||
>
|
||||
<Play />
|
||||
|
||||
</ActionButton>
|
||||
|
||||
{validCommands.length > 1 &&
|
||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
|
||||
<EllipsisVertical />
|
||||
</ActionButton>}</div>;
|
||||
}
|
||||
else if (error)
|
||||
{
|
||||
|
|
@ -354,8 +392,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
SaveSource('settings');
|
||||
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/settings/directories' });
|
||||
}
|
||||
}}
|
||||
id="mainAction">
|
||||
|
|
@ -366,12 +403,12 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
{
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
disabled={installMutation.isPending}
|
||||
disabled={installMut.isPending}
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'install')
|
||||
{
|
||||
installMutation.mutate();
|
||||
installMut.mutate();
|
||||
}
|
||||
}}
|
||||
tooltip={details ?? status}
|
||||
|
|
@ -381,10 +418,41 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
</ActionButton>;
|
||||
}
|
||||
|
||||
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
|
||||
content: <ContextList options={validCommands.map(c =>
|
||||
{
|
||||
const commands: DialogEntry = {
|
||||
id: String(c.id),
|
||||
content: c.label ?? "",
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
setPreferredCommand(c.id);
|
||||
handlePlay(c);
|
||||
},
|
||||
};
|
||||
return commands;
|
||||
})} />,
|
||||
preferredChildFocusKey: String(preferredCommand)
|
||||
});
|
||||
|
||||
const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', {
|
||||
content: <ContextList options={[{
|
||||
id: 'cancel',
|
||||
content: "Cancel",
|
||||
action (ctx)
|
||||
{
|
||||
ws.current?.send('cancel');
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary'
|
||||
}]} />
|
||||
});
|
||||
|
||||
return <div className="flex gap-2">
|
||||
{mainButton}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
{progress !== null && !!progressIcon && <ActionButton key="progress" square tooltip={details} type="base" id="progress" >
|
||||
{showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
|
||||
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
{progressIcon}
|
||||
|
|
@ -392,26 +460,34 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
{installOptionsDialog}
|
||||
{allCommandDialog}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
||||
function ActionButtons (data: {})
|
||||
{
|
||||
const [, setDetailsSection] = useDetailsSection();
|
||||
const { data: game } = Route.useLoaderData();
|
||||
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
...queries.romm.deleteGameMutation,
|
||||
...deleteGameMutation(game.id),
|
||||
onSuccess: () =>
|
||||
{
|
||||
location.reload();
|
||||
console.log("Deleted");
|
||||
},
|
||||
onError (error)
|
||||
{
|
||||
toast.error(getErrorMessage(error) ?? "Error While Deleting");
|
||||
}
|
||||
});
|
||||
|
||||
const contextOptions: DialogEntry[] = [];
|
||||
if (data.game.local)
|
||||
if (game.local)
|
||||
{
|
||||
contextOptions.push({
|
||||
id: 'delete',
|
||||
|
|
@ -451,16 +527,20 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
|
||||
return <div ref={ref} className="flex sm:gap-2 md:gap-4 sm:h-16 md:h-32 overflow-hidden p-2 items-center shrink-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<MainActions game={data.game} />
|
||||
<AchievementsInfo game={data.game} />
|
||||
<MainActions />
|
||||
<AchievementsInfo onAction={() =>
|
||||
{
|
||||
setDetailsSection("achievements");
|
||||
if (game.achievements?.entires[0])
|
||||
{
|
||||
setFocus(game.achievements.entires[0].id);
|
||||
}
|
||||
|
||||
}} />
|
||||
<ActionButton tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
|
||||
|
||||
</ActionButton >
|
||||
<ContextDialog id="settings-context" open={open} close={() =>
|
||||
{
|
||||
setOpen(false);
|
||||
setFocus("settings");
|
||||
}}>
|
||||
<ContextDialog sourceFocusKey="settings" id="settings-context" open={open} close={setOpen}>
|
||||
<ContextList options={contextOptions} />
|
||||
</ContextDialog>
|
||||
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
|
|
@ -496,7 +576,7 @@ function ActionButton (data: {
|
|||
const styles = {
|
||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
|
||||
accent: "bg-accent text-accent-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
|
||||
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
|
||||
};
|
||||
return (
|
||||
|
|
@ -516,45 +596,135 @@ function ActionButton (data: {
|
|||
);
|
||||
}
|
||||
|
||||
export default function GameDetailsUI ()
|
||||
function Stats ()
|
||||
{
|
||||
const { data } = Route.useLoaderData();
|
||||
const stats: StatEntry[] = [];
|
||||
if (data.path_fs)
|
||||
stats.push({ label: "Location", content: data.path_fs, icon: <Folder /> });
|
||||
if (data.companies)
|
||||
stats.push({ label: "Companies", content: data.companies });
|
||||
if (data.genres)
|
||||
stats.push({ label: 'Genres', content: data.genres });
|
||||
if (data.release_date)
|
||||
stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: <Calendar /> });
|
||||
if (data.emulators)
|
||||
stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) });
|
||||
return <StatList elementClassName="bg-base-300" stats={stats} />;
|
||||
}
|
||||
|
||||
function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
|
||||
{
|
||||
const [details, setDetails] = useDetailsSection();
|
||||
const { data: game } = Route.useLoaderData();
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: "details-divider",
|
||||
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||
});
|
||||
const detailFilter: Record<string, FilterOption> = {
|
||||
stats: { label: "Stats", selected: details === 'stats', icon: <Info /> },
|
||||
screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: <Image /> },
|
||||
};
|
||||
if (game.achievements)
|
||||
{
|
||||
detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: <Trophy /> };
|
||||
}
|
||||
|
||||
return <div ref={ref} className="divider justify-center bg-linear-to-t from-base-200 to-base-100 h-fit py-0 m-0 scroll-mt-32">
|
||||
<FocusContext value={focusKey}>
|
||||
<FilterUI showShortcuts={data.showShortcuts} rootFocusKey={data.rootFocusKey} className="bg-base-200 drop-shadow-none z-20 gap-1" id="details-filter" options={detailFilter} setSelected={setDetails} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function GameDetailsUI ()
|
||||
{
|
||||
const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false);
|
||||
const { data } = Route.useLoaderData();
|
||||
const { focus } = Route.useSearch();
|
||||
const [, setUpdate] = useState(0);
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible });
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focus)
|
||||
{
|
||||
setFocus(focus, { instant: true });
|
||||
} else
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
const recommendedEmulators = data.emulators?.filter(e => e.store_exists);
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
onChange: (isIntersecting, entry) =>
|
||||
{
|
||||
setRecommendedGamesVisible(isIntersecting);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
||||
<GameDetailsContext value={{
|
||||
update: () => setUpdate(v => v + 1)
|
||||
}} >
|
||||
<div className="z-10">
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<Details mainAreaRef={mainAreaRef} />
|
||||
</div>
|
||||
<MoreDetails />
|
||||
<div className="relative bg-base-300">
|
||||
{!!recommendedEmulators && recommendedEmulators.length > 0 && <EmulatorsSection
|
||||
id={`${data.id.id}-recommended`}
|
||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Related Emulators
|
||||
</h2></>}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
onSelect={(id, focus) =>
|
||||
{
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||
}}
|
||||
emulators={recommendedEmulators} />}
|
||||
</div>
|
||||
<div className="bg-base-100">
|
||||
<div className="px-6 py-3">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Related Games
|
||||
</h2>
|
||||
</div>
|
||||
<GamesSection ref={intersct} showSources onSelect={(id, focus) =>
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } });
|
||||
}} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} />
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>
|
||||
<footer className="fixed right-0 bottom-0 p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</GameDetailsContext>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
|
|||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground } from "../components/AnimatedBackground";
|
||||
import { GameList } from "../components/GameList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import LoadingCardList from "../components/LoadingCardList";
|
||||
import { AutoFocus } from "../components/AutoFocus";
|
||||
import SaveScroll from "../components/SaveScroll";
|
||||
|
|
@ -46,7 +45,7 @@ import { mobileCheck, useDragScroll } from "../scripts/utils";
|
|||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
import Carousel from "../components/Carousel";
|
||||
import queries from "../scripts/queries";
|
||||
import { closeMutation } from "@queries/system";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -125,20 +124,17 @@ function HomeList (data: {
|
|||
|
||||
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||
};
|
||||
|
||||
const handleCollectionSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: `/collection/${id}` });
|
||||
};
|
||||
|
||||
const handlePlatformSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: `/platform/${source}/${id}` });
|
||||
};
|
||||
|
||||
let activeList: JSX.Element;
|
||||
|
|
@ -224,7 +220,6 @@ function MainMenu ()
|
|||
focusKey: `main-menu`,
|
||||
trackChildren: true,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<ul
|
||||
ref={ref}
|
||||
|
|
@ -233,13 +228,13 @@ function MainMenu ()
|
|||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<CircleIcon
|
||||
action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })}
|
||||
action={() => Router.navigate({ to: "/games" })}
|
||||
icon={<Gamepad2 />}
|
||||
label="Home"
|
||||
type="secondary"
|
||||
/>
|
||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
|
||||
<CircleIcon type="info" icon={<Store />} action={() => Router.navigate({ to: "/store/tab" })} label="Shop" />
|
||||
<CircleIcon icon={<Image />} label="Album" />
|
||||
<CircleIcon
|
||||
icon={<Gamepad2 />}
|
||||
|
|
@ -248,8 +243,7 @@ function MainMenu ()
|
|||
<CircleIcon
|
||||
action={() =>
|
||||
{
|
||||
SaveSource('settings');
|
||||
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/settings/accounts' });
|
||||
}}
|
||||
icon={<Settings />}
|
||||
label="Settings"
|
||||
|
|
@ -294,7 +288,7 @@ export default function ConsoleHomeUI ()
|
|||
{
|
||||
const { filter } = Route.useSearch();
|
||||
|
||||
const close = useMutation(queries.system.closeMutation);
|
||||
const close = useMutation(closeMutation);
|
||||
|
||||
const { ref, focusKey } = useFocusable({
|
||||
forceFocus: true,
|
||||
|
|
@ -304,29 +298,7 @@ export default function ConsoleHomeUI ()
|
|||
preferredChildFocusKey: `home-list`,
|
||||
});
|
||||
|
||||
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } });
|
||||
|
||||
useShortcuts(focusKey, () => [
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [filter]);
|
||||
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
const headerButtons = [];
|
||||
|
|
@ -342,6 +314,7 @@ export default function ConsoleHomeUI ()
|
|||
</div>
|
||||
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
|
||||
<FilterUI
|
||||
rootFocusKey={focusKey}
|
||||
id="home"
|
||||
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
|
||||
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import Shortcuts from '../components/Shortcuts';
|
||||
import queries from '../scripts/queries';
|
||||
import { gameQuery } from '@queries/romm';
|
||||
|
||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -18,12 +18,12 @@ function RouteComponent ()
|
|||
{
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
|
||||
}
|
||||
|
||||
const { source, id } = Route.useParams();
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
|
||||
const { data } = useQuery(queries.romm.gameQuery(source, id));
|
||||
const { data } = useQuery(gameQuery(source, id));
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import queries from "../scripts/queries";
|
||||
import { platformQuery } from "@queries/romm";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
|
|
@ -22,7 +22,7 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string;
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data: platform } = useQuery(queries.romm.platformQuery(source, id));
|
||||
const { data: platform } = useQuery(platformQuery(source, id));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
|
||||
import { systemInfoQuery } from '@queries/system';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
|
@ -10,7 +11,7 @@ export const Route = createFileRoute('/settings/about')({
|
|||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery(queries.system.systemInfoQuery);
|
||||
const { data: systemInfo } = useQuery(systemInfoQuery);
|
||||
return <table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import QRCode from "react-qr-code";
|
|||
import { useJobStatus } from "@/mainview/scripts/utils";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings";
|
||||
import { rommGetOptionsQuery, rommHasPasswordQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
|
|
@ -52,14 +53,14 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url:
|
|||
|
||||
function TwitchLogin ()
|
||||
{
|
||||
const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery);
|
||||
const loginStatus = useQuery(twitchLoginVerificationQuery);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
...queries.settings.twitchLoginMutation,
|
||||
...twitchLoginMutation,
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({ ...queries.settings.twitchLogoutMutation, onSuccess: () => loginStatus.refetch() });
|
||||
const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() });
|
||||
|
||||
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
|
||||
|
||||
|
|
@ -84,13 +85,13 @@ function TwitchLogin ()
|
|||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery(queries.romm.rommUserQuery());
|
||||
const loginMutation = useMutation(queries.romm.rommQrLoginMutation);
|
||||
const user = useQuery(rommUserQuery());
|
||||
const loginMutation = useMutation(rommQrLoginMutation);
|
||||
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
||||
const context = useSettingsFormContext({});
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
...queries.romm.rommLogoutMutation,
|
||||
...rommLogoutMutation,
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
user.refetch();
|
||||
|
|
@ -136,9 +137,9 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery);
|
||||
const { data: hostname } = useQuery(queries.romm.rommHostnameQuery);
|
||||
const { data: username } = useQuery(queries.romm.rommUsernameQuery);
|
||||
const { data: hasPassword } = useQuery(rommHasPasswordQuery);
|
||||
const { data: hostname } = useQuery(rommHostnameQuery);
|
||||
const { data: username } = useQuery(rommUsernameQuery);
|
||||
|
||||
const loginForm = useSettingsForm({
|
||||
defaultValues: {
|
||||
|
|
@ -160,7 +161,7 @@ function RouteComponent ()
|
|||
}
|
||||
});
|
||||
|
||||
const rommOnline = useQuery(queries.romm.rommGetOptionsQuery());
|
||||
const rommOnline = useQuery(rommGetOptionsQuery());
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -170,7 +171,7 @@ function RouteComponent ()
|
|||
}
|
||||
}, [focus]);
|
||||
|
||||
const loginMutation = useMutation(queries.romm.rommLoginMutation);
|
||||
const loginMutation = useMutation(rommLoginMutation);
|
||||
|
||||
let indicator = "";
|
||||
if (rommOnline.isError)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
|||
import { Block, createFileRoute } from '@tanstack/react-router';
|
||||
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
||||
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { DownloadsDrive } from '@/shared/constants';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -13,6 +12,7 @@ import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
|||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||
import useActiveControl from '@/mainview/scripts/gamepads';
|
||||
import { changeDownloadsMutation } from '@queries/settings';
|
||||
|
||||
export const Route = createFileRoute('/settings/directories')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
|
|||
focusKey: data.drive.device,
|
||||
onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
});
|
||||
const isMoving = useIsMutating(queries.settings.changeDownloadsMutation);
|
||||
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||
const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
|
||||
const usedPercent = usedWithoutDownlods / data.drive.size;
|
||||
const usedPercentRaw = data.drive.used / data.drive.size;
|
||||
const changeDownloads = useMutation({ ...queries.settings.changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
|
||||
const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
|
||||
const shortcuts: Shortcut[] = [];
|
||||
const valid = !data.drive.unusableReason && isMoving <= 0;
|
||||
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
|
|||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import FilePicker from '@/mainview/components/FilePicker';
|
||||
import { dirname } from 'pathe';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings';
|
||||
|
||||
export const Route = createFileRoute('/settings/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -98,13 +98,13 @@ function EmulatorPath (data: { id: string; })
|
|||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id));
|
||||
const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) =>
|
||||
const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id));
|
||||
const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
setDirty(false);
|
||||
}));
|
||||
const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id));
|
||||
const deleteMutation = useMutation(customEmulatorDeleteMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
|
|
@ -223,11 +223,11 @@ function EmulatorBadge (data: {
|
|||
|
||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery(queries.settings.autoEmulatorsQuery);
|
||||
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
|
||||
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.path?.path} exists={e.exists} emulator={e.name} />)}
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.validSource?.binPath} exists={!!e.validSource} emulator={e.name} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -240,9 +240,9 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: customEmulators } = useQuery(queries.settings.customEmulatorsQuery);
|
||||
const { data: customEmulators } = useQuery(customEmulatorsQuery);
|
||||
|
||||
const addOverrideMutation = useMutation(queries.settings.customEmulatorAddMutation);
|
||||
const addOverrideMutation = useMutation(customEmulatorAddMutation);
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import
|
|||
Outlet,
|
||||
createFileRoute,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||
import classNames from "classnames";
|
||||
|
|
@ -25,10 +24,10 @@ import { JSX, useEffect } from "react";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import z from "zod";
|
||||
import { SettingsSchema } from "../../../shared/constants";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { Router } from "../..";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||
import { HandleGoBack } from "@/mainview/scripts/utils";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
|
|
@ -48,21 +47,26 @@ function MenuItem (data: {
|
|||
label: string;
|
||||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
||||
const handleNonFocusSelect = () =>
|
||||
{
|
||||
const { to, search } = PopSource('settings');
|
||||
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
|
||||
if (data.return)
|
||||
{
|
||||
HandleGoBack();
|
||||
} else if (!acitve)
|
||||
{
|
||||
Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
|
||||
}
|
||||
|
||||
};
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: `menu-item-${data.route}`,
|
||||
forceFocus: !!acitve,
|
||||
onFocus: () =>
|
||||
{
|
||||
if (data.focusSelect)
|
||||
if (data.focusSelect && !acitve)
|
||||
{
|
||||
navigate({ to: data.route });
|
||||
Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
|
||||
}
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center' });
|
||||
},
|
||||
|
|
@ -104,12 +108,13 @@ function SettingsMenu (data: {})
|
|||
const { ref, focusKey } = useFocusable({
|
||||
focusable: true,
|
||||
focusKey: 'settings-menu',
|
||||
preferredChildFocusKey: location.hash.replace("#", '')
|
||||
preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '')
|
||||
});
|
||||
|
||||
return <ul
|
||||
ref={ref}
|
||||
className="flex flex-col portrait:flex-row md:text-2xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
|
||||
style={{ viewTransitionName: 'settings-menu' }}
|
||||
>
|
||||
<FocusContext value={focusKey}>
|
||||
<MenuItem
|
||||
|
|
@ -147,25 +152,12 @@ function SettingsMenu (data: {})
|
|||
route={"/"}
|
||||
return
|
||||
label="Return"
|
||||
viewTransition={{ types: ['zoom-out'] }}
|
||||
icon={<ArrowBigLeft />}
|
||||
/>
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
|
||||
const { to, search } = PopSource('settings');
|
||||
if (to)
|
||||
{
|
||||
console.log("Found source ", to, " to go back to");
|
||||
}
|
||||
Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search });
|
||||
|
||||
}
|
||||
|
||||
export function SettingsUI ()
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
|
|
|
|||
|
|
@ -1,174 +1,264 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
setFocus,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { Router } from "@/mainview";
|
||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
||||
import { PopSource } from "@/mainview/scripts/spatialNavigation";
|
||||
import { systemApi } from "@/mainview/scripts/clientApi";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { Button } from "@/mainview/components/options/Button";
|
||||
import { ChevronDown, Download, Info, Settings } from "lucide-react";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
|
||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
||||
import { ChevronDown, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert } from "lucide-react";
|
||||
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
|
||||
import { FrontEndEmulatorDetailed, RPC_URL } from "@/shared/constants";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { HeaderUI } from "@/mainview/components/Header";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StickyHeaderUI } from "@/mainview/components/Header";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
|
||||
import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard";
|
||||
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
||||
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||
import { installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store";
|
||||
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
|
||||
|
||||
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||
component: RouteComponent,
|
||||
async loader (ctx)
|
||||
{
|
||||
const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id));
|
||||
return { emulator };
|
||||
ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
|
||||
ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery);
|
||||
ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id));
|
||||
}
|
||||
});
|
||||
|
||||
function HomePageLink (data: { homepage: string; })
|
||||
function HomePageLink (data: { homepage?: string; })
|
||||
{
|
||||
const { ref } = useFocusable({ focusKey: 'homepage-link' });
|
||||
return <a ref={ref} className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1" onClick={() => systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}</a>;
|
||||
return <a
|
||||
ref={ref}
|
||||
className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1"
|
||||
onClick={() =>
|
||||
{
|
||||
if (data.homepage) systemApi.api.system.open.post({ url: data.homepage });
|
||||
}}>
|
||||
{data.homepage ?? <div className="skeleton h-4 w-54" />}
|
||||
</a>;
|
||||
}
|
||||
|
||||
function TitleArea (data: { emulator: FrontEndEmulator; })
|
||||
function TitleArea (data: {
|
||||
emulator?: FrontEndEmulatorDetailed;
|
||||
onInstall: (source: string) => void;
|
||||
})
|
||||
{
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const installOptions: DialogEntry[] = [];
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
...storeEmulatorDeleteMutation, onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(variables)),
|
||||
});
|
||||
const installProgressRef = useRef<HTMLProgressElement>(null);
|
||||
const { data: installJob, status: installStatus } = useJobStatus('download-emulator', {
|
||||
onError (error)
|
||||
{
|
||||
console.log(error);
|
||||
toast.error(getErrorMessage(error) ?? "Error During Download");
|
||||
},
|
||||
onProgress (process)
|
||||
{
|
||||
if (installProgressRef.current)
|
||||
installProgressRef.current.value = process;
|
||||
},
|
||||
onEnded (data)
|
||||
{
|
||||
console.log("Finished Install", data.emulator);
|
||||
if (data.emulator)
|
||||
queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator));
|
||||
},
|
||||
});
|
||||
|
||||
const isInstalling = !!installJob;
|
||||
|
||||
const options: DialogEntry[] = [];
|
||||
if (data.emulator)
|
||||
{
|
||||
if (!isInstalling && !data.emulator?.validSource)
|
||||
{
|
||||
options.push(...data.emulator.downloads.map(d =>
|
||||
{
|
||||
const entry: DialogEntry = {
|
||||
content: `Install From: ${d.name} (${d.type})`,
|
||||
type: 'primary',
|
||||
id: d.name,
|
||||
action: (ctx) =>
|
||||
{
|
||||
data.onInstall(d.name);
|
||||
ctx.close();
|
||||
}
|
||||
};
|
||||
return entry;
|
||||
}));
|
||||
} else if (data.emulator.sources.find(s => s.type === 'store' && s.exists))
|
||||
{
|
||||
options.push({
|
||||
content: "Delete",
|
||||
type: 'error',
|
||||
icon: <Trash2 />,
|
||||
action (ctx)
|
||||
{
|
||||
if (data.emulator) deleteMutation.mutate(data.emulator.name);
|
||||
ctx.close();
|
||||
},
|
||||
id: "delete"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'title-area',
|
||||
preferredChildFocusKey: "install-btn",
|
||||
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-wrap gap-4 items-center">
|
||||
|
||||
let installButtonContent = <></>;
|
||||
if (!data.emulator)
|
||||
{
|
||||
installButtonContent = <span className="loading loading-spinner loading-lg"></span>;
|
||||
}
|
||||
else if (isInstalling)
|
||||
{
|
||||
installButtonContent = <><span className="loading loading-spinner loading-lg"></span>{installStatus}</>;
|
||||
} else if (data.emulator.validSource)
|
||||
{
|
||||
installButtonContent = <><Settings /> Options</>;
|
||||
} else if (data.emulator.downloads.length > 0)
|
||||
{
|
||||
installButtonContent = <><Download />Install</>;
|
||||
} else
|
||||
{
|
||||
installButtonContent = <><TriangleAlert />Unsupported</>;
|
||||
}
|
||||
|
||||
const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", {
|
||||
content: <ContextList options={options} />
|
||||
});
|
||||
|
||||
const handleOptionsOpen = () =>
|
||||
{
|
||||
if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false;
|
||||
setOpen(true, 'install-btn');
|
||||
};
|
||||
|
||||
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<img className="size-32" src={data.emulator.logo}></img>
|
||||
<div className="flex flex-col grow justify-start gap-1">
|
||||
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
|
||||
<p className="flex gap-2">
|
||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||
{data.emulator ? <img className="size-32" src={data.emulator.logo}></img> : <div className="skeleton h-32 w-32" />}
|
||||
<div className="flex flex-col grow gap-1 sm:portrait:items-center md:items-start">
|
||||
<h1 className="text-4xl font-semibold">{data.emulator?.name ?? <div className="skeleton h-10 w-84" />}</h1>
|
||||
<div className="flex gap-2">
|
||||
{data.emulator?.systems.map(({ id, name, icon }) =>
|
||||
{
|
||||
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
|
||||
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
|
||||
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
|
||||
</div>;
|
||||
})}
|
||||
</p>
|
||||
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
|
||||
</div>
|
||||
<div className="flex pt-2 gap-1">
|
||||
<HomePageLink homepage={data.emulator.homepage} />
|
||||
<HomePageLink homepage={data.emulator?.homepage} />
|
||||
</div>
|
||||
</div>
|
||||
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
|
||||
data.emulator.exists ?
|
||||
<><Settings /> Options</> :
|
||||
<><Download />Install</>
|
||||
}
|
||||
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4">
|
||||
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2" onAction={handleOptionsOpen} >
|
||||
<div className="flex gap-4">
|
||||
{installButtonContent}
|
||||
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
||||
<ChevronDown />
|
||||
</div>
|
||||
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
|
||||
</Button>
|
||||
|
||||
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
|
||||
{
|
||||
setInstallOpen(false);
|
||||
setFocus("install-btn");
|
||||
}}>
|
||||
<ContextList options={installOptions}>
|
||||
|
||||
</ContextList>
|
||||
</ContextDialog>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
</div>
|
||||
{installOptionsDialog}
|
||||
</FocusContext >
|
||||
</div >;
|
||||
}
|
||||
|
||||
function Description (data: { emulator: FrontEndEmulator; })
|
||||
function Description (data: { emulator?: FrontEndEmulatorDetailed; })
|
||||
{
|
||||
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
|
||||
<p>{data.emulator.description}</p>
|
||||
<p>{data.emulator?.description ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[40%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
</div>}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: `GAME_DETAIL_${id}`,
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: 'title-area'
|
||||
});
|
||||
|
||||
const { emulator } = Route.useLoaderData();
|
||||
const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
||||
const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id));
|
||||
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
|
||||
const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id));
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
action: () =>
|
||||
{
|
||||
const { to, search } = PopSource('store-details');
|
||||
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
|
||||
},
|
||||
action: HandleGoBack,
|
||||
button: GamePadButtonCode.B
|
||||
}]);
|
||||
|
||||
const installMutation = useMutation({
|
||||
...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
|
||||
const stats: StatEntry[] = [];
|
||||
if (emulator)
|
||||
{
|
||||
if (emulator.keywords)
|
||||
stats.push({ label: "Tags", content: emulator.keywords });
|
||||
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
|
||||
stats.push(...emulator.sources.flatMap(s => [{ label: "Source", content: s.type, icon: emulatorStatusIcons[s.type] }, { label: "Location", content: s.binPath }]));
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
|
||||
<AnimatedBackground ref={ref} className="" scrolling>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="flex flex-col min-h-full z-10">
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
|
||||
<HeaderUI />
|
||||
<StickyHeaderUI ref={ref} />
|
||||
<div className="flex flex-col z-10">
|
||||
<div className="w-full sm:px-8 md:px-16 pb-8 pt-12">
|
||||
<TitleArea emulator={emulator} onInstall={installMutation.mutate} />
|
||||
|
||||
<div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div>
|
||||
<div className='mobile:hidden left-0 top-0 absolute bg-noise'></div>
|
||||
</div>
|
||||
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
|
||||
<TitleArea emulator={emulator} />
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-200 pt-4 min-h-0 grow text-lg">
|
||||
<Screenshots screenshots={emulator.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />
|
||||
<div className="flex flex-col bg-base-100 gap-4 pt-4 h-[50vh] min-h-128 grow text-lg">
|
||||
{isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && <Screenshots className="grow bg-base-200" screenshots={emulator?.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />}
|
||||
<Description emulator={emulator} />
|
||||
</div>
|
||||
<div className='mobile:hidden bg-gradient'></div>
|
||||
<div className='mobile:hidden bg-noise'></div>
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-100 py-4">
|
||||
<div className="flex flex-col bg-base-100 py-4 gap-12 z-10">
|
||||
<div className="divider"> <Info className="size-12" /> Stats</div>
|
||||
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
|
||||
{!!emulator.keywords &&
|
||||
<li className="flex flex-wrap gap-2 items-center">
|
||||
<div className="font-semibold">Tags:</div>
|
||||
<div className="flex flex-wrap gap-2">{emulator.keywords?.map(k => <span className="rounded-full bg-base-200 px-3 py-1">{k}</span>)}</div>
|
||||
</li>
|
||||
}
|
||||
{!!emulator.status.source &&
|
||||
<li>
|
||||
<div>Source</div>
|
||||
<div>{emulator.status.source}</div>
|
||||
</li>
|
||||
}
|
||||
{!!emulator.status.location &&
|
||||
<li>
|
||||
<div>Location</div>
|
||||
<div>{emulator.status.location}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div className="relative mt-16 bg-base-200">
|
||||
{recommended && <EmulatorsSection
|
||||
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
|
||||
{recommendedEmulators && <div className="relative bg-base-200">
|
||||
<EmulatorsSection
|
||||
id={`${id}-recommended`}
|
||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
|
|
@ -177,11 +267,26 @@ export function RouteComponent ()
|
|||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
onSelect={(id, focus) =>
|
||||
{
|
||||
setFocus("title-area");
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({
|
||||
to: '/store/details/emulator/$id', params: { id }
|
||||
});
|
||||
}}
|
||||
emulators={recommended} />}
|
||||
emulators={recommendedEmulators} />
|
||||
</div>}
|
||||
{recommendedGames && recommendedGames.length > 0 && <div className="px-6 py-3">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Related Games
|
||||
</h2>
|
||||
</div>
|
||||
<GamesSection showSources={true} onFocus={scrollIntoViewHandler({ behavior: 'smooth', block: 'center' })} onSelect={(id) =>
|
||||
{
|
||||
Router.navigate({
|
||||
to: '/game/$source/$id', params: { id: id.id, source: id.source }
|
||||
});
|
||||
}} games={recommendedGames} /></div>}
|
||||
</div>
|
||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard
|
|||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { storeEmulatorsQuery } from '@queries/store';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -22,7 +22,7 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
const storeContext = useContext(StoreContext);
|
||||
const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery);
|
||||
const { data: emulators } = useQuery(storeEmulatorsQuery);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
|
|||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { storeGamesInfiniteQuery } from '@queries/store';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent
|
||||
|
|
@ -17,7 +17,7 @@ function RouteComponent ()
|
|||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery);
|
||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -52,7 +52,7 @@ function RouteComponent ()
|
|||
<div className="skeleton h-4 w-[40%]"></div>
|
||||
</div>)}
|
||||
<LoadMoreButton
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
||||
onFocus={handleFocus}
|
||||
isFetching={isFetchingNextPage || isFetching}
|
||||
onAction={() =>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@ import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
|||
import { GamesSection } from "../../../components/store/GamesSection";
|
||||
import { StatsSection } from "../../../components/store/StatsSection";
|
||||
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { HardDrive, Search } from 'lucide-react';
|
||||
import { Gamepad2, HardDrive, Search, Star } from 'lucide-react';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { autoEmulatorsQuery } from '@queries/settings';
|
||||
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/')({
|
||||
component: RouteComponent
|
||||
|
|
@ -106,9 +107,9 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
|||
export function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) });
|
||||
const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery);
|
||||
const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
||||
const { data: crucialEmulators, isSuccess } = useQuery({ ...autoEmulatorsQuery, select: (data) => data.filter(e => !e.validSource && e.isCritical) });
|
||||
const { data: featuredGames } = useQuery(storeFeaturedGamesQuery);
|
||||
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery);
|
||||
|
||||
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
|
@ -137,11 +138,22 @@ export function RouteComponent ()
|
|||
emulators={recommendedEmulators} />
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Featured Games
|
||||
</h2>
|
||||
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
|
||||
</div>
|
||||
<GamesSection
|
||||
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
games={featuredGames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<StatsSection
|
||||
romCount={1240}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { HeaderUI } from '@/mainview/components/Header';
|
|||
import Shortcuts from '@/mainview/components/Shortcuts';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import { SaveSource } from '@/mainview/scripts/spatialNavigation';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
|
|
@ -33,29 +33,51 @@ function TopArea (data: { filters: Record<string, FilterOption>; })
|
|||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'top-area',
|
||||
preferredChildFocusKey: 'store-tabs',
|
||||
preferredChildFocusKey: `store-tabs`,
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts("STORE_ROOT", () => [{
|
||||
label: "Return",
|
||||
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
|
||||
button: GamePadButtonCode.B
|
||||
}], []);
|
||||
|
||||
const handleNavigate = (s: string) =>
|
||||
{
|
||||
Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true });
|
||||
};
|
||||
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className='w-full'>
|
||||
<FilterUI containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters} setSelected={(s) => Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} />
|
||||
<FilterUI rootFocusKey='STORE_ROOT' containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters}
|
||||
setSelected={handleNavigate} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function StoreOutlet ()
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "STORE_OUTLET" });
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
<Outlet />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
// Root spatial nav container
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "STORE_ROOT",
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: 'top-area'
|
||||
preferredChildFocusKey: 'top-area',
|
||||
forceFocus: true
|
||||
});
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
|
|
@ -65,34 +87,6 @@ function RouteComponent ()
|
|||
games: { label: "Games", selected: useIsSettings('games') }
|
||||
};
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
|
||||
button: GamePadButtonCode.B
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [filters]);
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
const { focus } = Route.useSearch();
|
||||
|
||||
|
|
@ -102,31 +96,24 @@ function RouteComponent ()
|
|||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
||||
{
|
||||
|
||||
if (type === 'emulator')
|
||||
{
|
||||
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||
}
|
||||
else if (type === 'game')
|
||||
{
|
||||
console.log(source, id);
|
||||
SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } });
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const match = Route.useMatch();
|
||||
const goToSettings = () =>
|
||||
{
|
||||
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
|
||||
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/settings' });
|
||||
};
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
|
|
@ -141,7 +128,7 @@ function RouteComponent ()
|
|||
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
||||
</div>
|
||||
<TopArea filters={filters} />
|
||||
<Outlet />
|
||||
<StoreOutlet />
|
||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="
|
|||
<title>Twitch</title>
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||
</svg>;
|
||||
|
||||
export const FlatpackIcon = <svg role="img" width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Flathub</title><path d="M6.068 0a6 6 0 0 0-6 6 6 6 0 0 0 6 6 6 6 0 0 0 5.998-6 6 6 0 0 0-5.998-6Zm9.15.08a1.656 1.656 0 0 0-1.654 1.656v8.15a1.656 1.656 0 0 0 2.483 1.434l7.058-4.074a1.656 1.656 0 0 0 0-2.869l-1.044-.604-6.014-3.47a1.656 1.656 0 0 0-.828-.223Zm3.575 13.135a.815.815 0 0 0-.816.818v2.453h-2.454a.817.817 0 1 0 0 1.635h2.454v2.453a.817.817 0 1 0 1.635 0v-2.453h2.452a.817.817 0 1 0 0-1.635h-2.453v-2.453a.817.817 0 0 0-.818-.818zM2.865 13.5a2.794 2.794 0 0 0-2.799 2.8v4.9c0 1.55 1.248 2.8 2.8 2.8h4.9c1.55 0 2.8-1.25 2.8-2.8v-4.9c0-1.55-1.25-2.8-2.8-2.8Z" /></svg>;
|
||||
|
|
@ -32,3 +32,7 @@ export const FilePickerContext = createContext<{
|
|||
drives: Drive[],
|
||||
activeDrive: Drive | undefined;
|
||||
}>({} as any);
|
||||
|
||||
export const GameDetailsContext = createContext<{
|
||||
update: () => void;
|
||||
}>({} 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,8 +4,7 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
|||
import z from "zod";
|
||||
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||
|
||||
export default {
|
||||
allGamesQuery: (filter?: GameListFilterType) => queryOptions({
|
||||
export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({
|
||||
queryKey: ['games', filter ?? 'all'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -13,8 +12,8 @@ export default {
|
|||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
gameQuery: (source: string, id: string) => queryOptions({
|
||||
});
|
||||
export const gameQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -22,13 +21,13 @@ export default {
|
|||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }),
|
||||
rommQrLoginMutation: mutationOptions({
|
||||
});
|
||||
export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() });
|
||||
export const rommQrLoginMutation = mutationOptions({
|
||||
mutationKey: ['login', 'qr', 'cancel'],
|
||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||
}),
|
||||
rommLoginMutation: mutationOptions({
|
||||
});
|
||||
export const rommLoginMutation = mutationOptions({
|
||||
mutationKey: ["romm", "login"],
|
||||
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
|
||||
{
|
||||
|
|
@ -43,37 +42,78 @@ export default {
|
|||
{
|
||||
console.error(e);
|
||||
},
|
||||
}),
|
||||
rommUserQuery: () => queryOptions({
|
||||
});
|
||||
export const rommUserQuery = () => queryOptions({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
queryKey: ['romm', 'auth', "login"],
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
}),
|
||||
rommGetOptionsQuery: () => queryOptions({
|
||||
});
|
||||
export const rommGetOptionsQuery = () => queryOptions({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
}),
|
||||
rommHasPasswordQuery: queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }),
|
||||
rommHostnameQuery: queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }),
|
||||
rommUsernameQuery: queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }),
|
||||
deleteGameMutation: (id: FrontEndId) => mutationOptions({
|
||||
});
|
||||
export const rommHasPasswordQuery = queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
export const deleteGameMutation = (id: FrontEndId) => mutationOptions({
|
||||
mutationKey: ['delete', id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
||||
}),
|
||||
getCollectionsQuery: () => queryOptions({
|
||||
});
|
||||
export const getCollectionsQuery = () => queryOptions({
|
||||
...getCollectionsApiCollectionsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime
|
||||
}),
|
||||
getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }),
|
||||
platformQuery: (source: string, id: string) => queryOptions({
|
||||
});
|
||||
export const getCollectionQuery = (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) });
|
||||
export const platformQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ['platform', source, id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}, staleTime: DefaultRommStaleTime
|
||||
})
|
||||
};
|
||||
});
|
||||
export const installMutation = (source: string, id: string) => mutationOptions({
|
||||
mutationKey: ['install', source, id],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source })({ id }).install.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
export const cancelInstallMutation = (source: string, id: string) => mutationOptions({
|
||||
mutationKey: ['install', 'cancel', source, id],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source })({ id }).install.delete();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
export const playMutation = mutationOptions({
|
||||
mutationKey: ['play'],
|
||||
mutationFn: async (data: { source: string, id: string; command_id?: string | number; }) =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.source })({ id: data.id }).play.post({ command_id: data.command_id });
|
||||
if (error)
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
export const gamesRecommendedBasedOnEmulatorQuery = (id: string) => queryOptions({
|
||||
queryKey: ['games', 'recommended', 'emulator', id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.recommended.games.emulator({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ['games', 'recommended', 'game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.recommended.games.game({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
|
@ -3,8 +3,7 @@ import { getErrorMessage } from "react-error-boundary";
|
|||
import toast from "react-hot-toast";
|
||||
import { rommApi, settingsApi } from "../clientApi";
|
||||
|
||||
export default {
|
||||
changeDownloadsMutation: mutationOptions({
|
||||
export const changeDownloadsMutation = mutationOptions({
|
||||
mutationKey: ["setting", "downloads"],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
|
|
@ -20,30 +19,30 @@ export default {
|
|||
|
||||
return response;
|
||||
}
|
||||
}),
|
||||
autoEmulatorsQuery: queryOptions({
|
||||
});
|
||||
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;
|
||||
}
|
||||
}),
|
||||
twitchLogoutMutation: mutationOptions({
|
||||
});
|
||||
export const twitchLogoutMutation = mutationOptions({
|
||||
mutationKey: ['twitch', 'logout'],
|
||||
mutationFn: () =>
|
||||
{
|
||||
return rommApi.api.romm.logout.twitch.post();
|
||||
}
|
||||
}),
|
||||
twitchLoginMutation: mutationOptions({
|
||||
});
|
||||
export const twitchLoginMutation = mutationOptions({
|
||||
mutationKey: ['twitch', 'login'],
|
||||
mutationFn: (openInBrowser: boolean) =>
|
||||
{
|
||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
||||
}
|
||||
}),
|
||||
twitchLoginVerificationQuery: queryOptions({
|
||||
});
|
||||
export const twitchLoginVerificationQuery = queryOptions({
|
||||
queryKey: ['twitch', 'login', 'status'],
|
||||
retry (failureCount, error)
|
||||
{
|
||||
|
|
@ -59,16 +58,16 @@ export default {
|
|||
if (error) throw { ...error, status };
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
customEmulatorsQuery: queryOptions({
|
||||
});
|
||||
export const customEmulatorsQuery = queryOptions({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
customEmulatorAddMutation: mutationOptions({
|
||||
});
|
||||
export const customEmulatorAddMutation = mutationOptions({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
|
|
@ -77,8 +76,8 @@ export default {
|
|||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
}),
|
||||
customEmulatorDeleteMutation: (id: string) => mutationOptions({
|
||||
});
|
||||
export const customEmulatorDeleteMutation = (id: string) => mutationOptions({
|
||||
mutationKey: ["emulator", id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
|
|
@ -90,8 +89,8 @@ export default {
|
|||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
}),
|
||||
setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({
|
||||
});
|
||||
export const setCustomEmulatorMutation = (id: string, onSuccess?: (value: string) => void) => mutationOptions({
|
||||
mutationKey: ["emulator", id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
|
|
@ -100,8 +99,8 @@ export default {
|
|||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
onSuccess?.(v);
|
||||
}
|
||||
}),
|
||||
customEmulatorRemoveValueQuery: (id?: string) => queryOptions({
|
||||
});
|
||||
export const customEmulatorRemoveValueQuery = (id?: string) => queryOptions({
|
||||
enabled: !!id,
|
||||
queryKey: ["emulator", id],
|
||||
queryFn: async () =>
|
||||
|
|
@ -110,8 +109,8 @@ export default {
|
|||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
setSettingMutation: (id?: string) => mutationOptions({
|
||||
});
|
||||
export const setSettingMutation = (id?: string) => mutationOptions({
|
||||
mutationKey: ["setting", id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
|
|
@ -119,8 +118,8 @@ export default {
|
|||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
}
|
||||
}),
|
||||
getSettingQuery: (id: string | undefined) => queryOptions({
|
||||
});
|
||||
export const getSettingQuery = (id: string | undefined) => queryOptions({
|
||||
enabled: !!id,
|
||||
queryKey: ["setting", id],
|
||||
queryFn: async () =>
|
||||
|
|
@ -130,5 +129,4 @@ export default {
|
|||
|
||||
return value.value;
|
||||
},
|
||||
})
|
||||
};
|
||||
});
|
||||
|
|
@ -1,41 +1,49 @@
|
|||
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { rommApi, storeApi } from "../clientApi";
|
||||
import { FrontEndGameType } from "@/shared/constants";
|
||||
|
||||
export default {
|
||||
storeEmulatorsQuery: queryOptions({
|
||||
|
||||
export const storeEmulatorsQuery = queryOptions({
|
||||
queryKey: ['store-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeFeaturedGamesQuery: queryOptions({
|
||||
});
|
||||
export const storeFeaturedGamesQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.games.featured.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeEmulatorsRecommendedQuery: queryOptions({
|
||||
});
|
||||
export const storeEmulatorsRecommendedQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeEmulatorDetailsQuery: (id: string) => queryOptions({
|
||||
});
|
||||
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
|
||||
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;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
});
|
||||
export const storeEmulatorDeleteMutation = mutationOptions({
|
||||
mutationKey: ['store-emulator', 'delete'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { error } = await storeApi.api.store.emulator({ id }).delete();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
initialPageParam: 0,
|
||||
queryKey: ['store-games'],
|
||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||
|
|
@ -46,13 +54,21 @@ export default {
|
|||
if (error) throw error;
|
||||
return { data: games.games, nextPage: pageParam + 1 };
|
||||
}
|
||||
}),
|
||||
storeGetStatsQuery: queryOptions({
|
||||
});
|
||||
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,8 +1,7 @@
|
|||
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { systemApi } from "../clientApi";
|
||||
|
||||
export default {
|
||||
drivesQuery: queryOptions({
|
||||
export const drivesQuery = queryOptions({
|
||||
queryKey: ['drives'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -10,8 +9,8 @@ export default {
|
|||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
downloadDrivesQuery: queryOptions({
|
||||
});
|
||||
export const downloadDrivesQuery = queryOptions({
|
||||
queryKey: ['drives', 'download'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -19,8 +18,8 @@ export default {
|
|||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
filesQuery: (currentPath: string | undefined, id: string) => queryOptions({
|
||||
});
|
||||
export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({
|
||||
queryKey: ['files', currentPath ?? '', id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -29,9 +28,9 @@ export default {
|
|||
return data;
|
||||
},
|
||||
placeholderData: keepPreviousData
|
||||
}),
|
||||
systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }),
|
||||
createFolderMutation: (id: string) => mutationOptions({
|
||||
});
|
||||
export const systemInfoQuery = queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
export const createFolderMutation = (id: string) => mutationOptions({
|
||||
|
||||
mutationKey: ['create', 'folder', id],
|
||||
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
|
||||
|
|
@ -40,12 +39,11 @@ export default {
|
|||
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
|
||||
if (error) throw error.value;
|
||||
},
|
||||
}),
|
||||
closeMutation: mutationOptions({
|
||||
});
|
||||
export const closeMutation = mutationOptions({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
|
@ -9,8 +9,6 @@ import
|
|||
UseFocusableResult,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { Router } from "..";
|
||||
import { RouteIds } from "@tanstack/react-router";
|
||||
|
||||
init({
|
||||
shouldFocusDOMNode: false,
|
||||
|
|
@ -22,43 +20,10 @@ let updateFocusable = SpatialNavigation.updateFocusable.bind(SpatialNavigation);
|
|||
let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation);
|
||||
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
||||
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
|
||||
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
|
||||
|
||||
type SaveFocusType = "session" | "local";
|
||||
|
||||
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list' | 'store-details';
|
||||
const historySourceMap = new Map<string, { to: string, search?: Record<string, any>; }>();
|
||||
|
||||
export function SaveSource (id: HistorySourceType, init?: { url?: string, search?: Record<string, any>; })
|
||||
{
|
||||
let finalUrl = init?.url ?? location.hash.replaceAll(/#|(\?.+)/g, '');
|
||||
if (finalUrl)
|
||||
{
|
||||
historySourceMap.set(id, { to: finalUrl, search: init?.search });
|
||||
}
|
||||
}
|
||||
|
||||
export function HasSource (id: HistorySourceType)
|
||||
{
|
||||
return historySourceMap.has(id);
|
||||
}
|
||||
|
||||
export function PopSource (id: HistorySourceType)
|
||||
{
|
||||
if (!historySourceMap.has(id))
|
||||
{
|
||||
return { to: undefined, search: undefined };
|
||||
}
|
||||
const source = historySourceMap.get(id);
|
||||
historySourceMap.delete(id);
|
||||
return source ?? { to: undefined, search: undefined };
|
||||
}
|
||||
|
||||
export function PopNavigateSource (id: HistorySourceType, fallback: RouteIds<typeof Router.routeTree>)
|
||||
{
|
||||
const { to, search } = PopSource(id);
|
||||
Router.navigate({ to: to ?? fallback, viewTransition: { types: ['zoom-out'] }, search });
|
||||
}
|
||||
|
||||
export function GetFocusedElement (focusKey: string)
|
||||
{
|
||||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement | undefined;
|
||||
|
|
@ -128,6 +93,11 @@ SpatialNavigation.setFocus = (newFocusKey, focusDetails) =>
|
|||
dispatchFocusedEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
|
||||
};
|
||||
|
||||
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
|
||||
{
|
||||
setCurrentFocusedKey(newFocusKey, focusDetails);
|
||||
window.dispatchEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
|
||||
};
|
||||
|
||||
SpatialNavigation.updateFocusable = (key, data) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { FrontEndId } from "@/shared/constants";
|
||||
|
||||
export const FOCUS_KEYS = {
|
||||
NAV_CATEGORIES: "NAV_CATEGORIES",
|
||||
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
||||
|
|
@ -6,6 +8,6 @@ export const FOCUS_KEYS = {
|
|||
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
||||
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
||||
GAME_SECTION: "GAME_SECTION",
|
||||
GAME_CARD: (id: string) => `GAME_${id}`,
|
||||
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
|
||||
STATS_SECTION: "STATS_SECTION",
|
||||
} as const;
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
||||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { doesFocusableExist, FocusableComponentLayout, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { jobsApi } from "./clientApi";
|
||||
import { JobsAPIType } from "@/bun/api/rpc";
|
||||
import { Router } from "..";
|
||||
import data from "@emulators";
|
||||
|
||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
||||
{
|
||||
|
|
@ -224,10 +226,14 @@ export function useDragScroll<T extends HTMLElement | null> (ref: RefObject<T>)
|
|||
|
||||
export function scrollIntoViewHandler (params?: ScrollIntoViewOptions)
|
||||
{
|
||||
return (focusKey: string, node: HTMLElement, details: any) => node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' });
|
||||
return (focusKey: string, node: HTMLElement, details: any) =>
|
||||
{
|
||||
if (details.nativeEvent instanceof PointerEvent) return;
|
||||
node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' });
|
||||
};
|
||||
}
|
||||
|
||||
export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement, T3 extends HTMLElement> (ref: RefObject<T | null>, sentinelRef: RefObject<T2 | null>, scrollRef: RefObject<T3 | null>)
|
||||
export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement, T3 extends HTMLElement> (ref: RefObject<T | null>, sentinelRef: RefObject<T2 | null>, scrollRef: RefObject<T3 | null>, callback?: (stuck: boolean) => void)
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -239,6 +245,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
|
|||
([entry]) =>
|
||||
{
|
||||
el.toggleAttribute("data-stuck", !entry.isIntersecting);
|
||||
callback?.(!entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
root: scrollRef.current ?? null,
|
||||
|
|
@ -249,7 +256,7 @@ export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement,
|
|||
observer.observe(sentinel);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef.current]);
|
||||
}, [scrollRef.current, callback]);
|
||||
}
|
||||
|
||||
type ExtractField<T, TYPE, K extends string> =
|
||||
|
|
@ -261,18 +268,19 @@ type JobResponse<JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> =
|
|||
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
|
||||
id: JOB,
|
||||
init?: {
|
||||
onProgress?: (process: number) => void,
|
||||
onEnded?: () => void;
|
||||
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress", 'data'>) => void,
|
||||
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
)
|
||||
{
|
||||
type Response = JobResponse<JOB>;
|
||||
type DataPayload = ExtractField<Response, 'data' | 'progress' | 'started', 'data'>;
|
||||
type DataPayload = ExtractField<Response, 'data' | 'progress' | 'started' | 'ended' | 'completed', 'data'>;
|
||||
|
||||
const ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
|
||||
const [data, setData] = useState<DataPayload>();
|
||||
const [status, setStatus] = useState<string>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -287,9 +295,13 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
|||
setError(data.error);
|
||||
setStatus(status);
|
||||
setData(undefined);
|
||||
init?.onError?.(data.error);
|
||||
break;
|
||||
case 'ended':
|
||||
init?.onEnded?.();
|
||||
setStatus(status);
|
||||
setData(undefined);
|
||||
init?.onEnded?.(data.data as any);
|
||||
break;
|
||||
case 'completed':
|
||||
setStatus(status);
|
||||
setData(undefined);
|
||||
|
|
@ -297,7 +309,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
|||
default:
|
||||
setData(data.data as DataPayload);
|
||||
setStatus(status);
|
||||
init?.onProgress?.(data.progress);
|
||||
init?.onProgress?.(data.progress, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -311,3 +323,13 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
|||
return { data, status, error, wsRef: ref };
|
||||
}
|
||||
|
||||
export function HandleGoBack ()
|
||||
{
|
||||
if (Router.history.canGoBack())
|
||||
{
|
||||
Router.history.back();
|
||||
} else
|
||||
{
|
||||
Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
}
|
||||
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;
|
||||
selected: boolean;
|
||||
icon?: any;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
|
||||
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { JSX } from 'react';
|
||||
import * as z from 'zod';
|
||||
|
|
@ -128,27 +129,73 @@ export const EmulatorPackageSchema = z.object({
|
|||
type: z.enum(['emulator']),
|
||||
os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
downloads: z.record(z.string(), z.object({ type: z.string(), url: z.url() })).optional(),
|
||||
downloads: z.record(z.string(), z.array(z.object({
|
||||
type: z.string(),
|
||||
url: z.url().optional(),
|
||||
pattern: z.string(),
|
||||
path: z.string().optional()
|
||||
}))).optional(),
|
||||
systems: z.array(z.string())
|
||||
});
|
||||
|
||||
export const GithubReleaseSchema = z.object({
|
||||
assets: z.array(z.object({
|
||||
name: z.string(),
|
||||
browser_download_url: z.url(),
|
||||
content_type: z.string().optional()
|
||||
}))
|
||||
});
|
||||
|
||||
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
|
||||
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
||||
|
||||
export interface FrontEndEmulator extends Omit<EmulatorPackageType, 'systems'>
|
||||
export interface EmulatorSourceType
|
||||
{
|
||||
binPath: string;
|
||||
rootPath?: string;
|
||||
type: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface FrontEndEmulator
|
||||
{
|
||||
name: string;
|
||||
logo: string;
|
||||
systems: { id: string, name: string, icon: string; }[];
|
||||
gameCount: number;
|
||||
exists: boolean;
|
||||
validSource?: EmulatorSourceType;
|
||||
}
|
||||
|
||||
export interface FrontEndEmulatorDetailedDownload
|
||||
{
|
||||
name: string;
|
||||
type: string | undefined;
|
||||
}
|
||||
|
||||
export interface FrontEndEmulatorDetailed extends FrontEndEmulator
|
||||
{
|
||||
homepage: string;
|
||||
description: string;
|
||||
downloads: FrontEndEmulatorDetailedDownload[];
|
||||
keywords?: string[];
|
||||
screenshots: string[];
|
||||
status: {
|
||||
source?: string;
|
||||
location?: string;
|
||||
};
|
||||
sources: EmulatorSourceType[];
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailedAchievement
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
date?: Date;
|
||||
date_hardcode?: Date;
|
||||
badge_url?: string;
|
||||
display_order: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export interface FrontEndGameTypeDetailed extends FrontEndGameType
|
||||
|
|
@ -157,9 +204,14 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType
|
|||
fs_size_bytes: number | null;
|
||||
missing: boolean;
|
||||
local: boolean;
|
||||
genres?: string[];
|
||||
companies?: string[];
|
||||
release_date?: Date;
|
||||
emulators?: FrontEndGameTypeDetailedEmulator[],
|
||||
achievements?: {
|
||||
unlocked: number;
|
||||
total: number;
|
||||
entires: FrontEndGameTypeDetailedAchievement[];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -195,6 +247,7 @@ export interface Notification
|
|||
title?: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface CommandEntry
|
||||
|
|
@ -202,10 +255,15 @@ export interface CommandEntry
|
|||
id: string | number;
|
||||
label?: string;
|
||||
command: string;
|
||||
startDir?: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
|
||||
|
||||
export type SettingsType = z.infer<typeof SettingsSchema>;
|
||||
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
||||
export interface GameInstallProgress
|
||||
|
|
@ -229,4 +287,4 @@ export const GameflowPluginSchema = z.object({
|
|||
launchGame: z.function({ input: [GameLaunchSchema] })
|
||||
});
|
||||
export interface GameflowPlugin extends z.infer<typeof GameflowPluginSchema> { }
|
||||
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing';
|
||||
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';
|
||||
|
|
@ -34,6 +34,9 @@
|
|||
"@schema/*": [
|
||||
"./src/bun/api/schema/*"
|
||||
],
|
||||
"@queries/*": [
|
||||
"./src/mainview/scripts/queries/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="AX360E">
|
||||
<!-- Microsoft Xbox 360 emulator aX360e -->
|
||||
<rule type="androidpackage">
|
||||
<entry>aenu.ax360e/aenu.ax360e.EmulatorActivity</entry>
|
||||
<entry>aenu.ax360e.free/aenu.ax360e.EmulatorActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="AZAHAR">
|
||||
<!-- Nintendo 3DS emulator Azahar -->
|
||||
<rule type="androidpackage">
|
||||
|
|
@ -372,6 +379,12 @@
|
|||
<entry>com.PceEmu/com.imagine.BaseActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="PICO-8">
|
||||
<!-- PICO-8 Fantasy Console (game engine) -->
|
||||
<rule type="androidpackage">
|
||||
<entry>io.wip.pico8/com.godot.game.GodotAppLauncher</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="PIZZA-BOY-GBA">
|
||||
<!-- Nintendo Game Boy Advance emulator Pizza Boy GBA -->
|
||||
<rule type="androidpackage">
|
||||
|
|
@ -462,6 +475,12 @@
|
|||
<entry>com.fms.speccy/com.fms.emulib.TVActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="SUPER3">
|
||||
<!-- Sega Model 3 emulator SUPER3 -->
|
||||
<rule type="androidpackage">
|
||||
<entry>com.izzy2lost.super3/com.izzy2lost.super3.MainActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="SWAN-EMU">
|
||||
<!-- Bandai WonderSwan emulator Swan.emu -->
|
||||
<rule type="androidpackage">
|
||||
|
|
@ -513,6 +532,12 @@
|
|||
<entry>com.cmodded.winlator/com.winlator.XServerDisplayActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="X1-BOX">
|
||||
<!-- Microsoft Xbox emulator X1 BOX -->
|
||||
<rule type="androidpackage">
|
||||
<entry>com.izzy2lost.x1box/.LauncherActivity</entry>
|
||||
</rule>
|
||||
</emulator>
|
||||
<emulator name="YABASANSHIRO-2">
|
||||
<!-- Sega Saturn emulator Yaba Sanshiro 2 -->
|
||||
<rule type="androidpackage">
|
||||
|
|
|
|||
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 (Standalone)">%EMULATOR_FLYCAST% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
|
||||
<command label="Play! (Standalone)">%EMULATOR_PLAY!% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
|
||||
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF%</command>
|
||||
<platform>arcade</platform>
|
||||
<theme>consolearcade</theme>
|
||||
</system>
|
||||
|
|
@ -1060,6 +1061,7 @@
|
|||
<fullname>Sega Model 3</fullname>
|
||||
<path>%ROMPATH%/model3</path>
|
||||
<extension>.7z .7Z .zip .ZIP</extension>
|
||||
<command label="SUPER3 (Standalone)">%EMULATOR_SUPER3% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
|
||||
<command label="MAME - Current">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/mamearcade_libretro_android.so %EXTRA_ROM%=%ROM%</command>
|
||||
<command label="MAME4droid Current (Standalone)">%EMULATOR_MAME4DROID-CURRENT% %ACTION%=android.intent.action.VIEW %EXTRA_cli_params%="-rompath '%GAMEDIRRAW%;%ROMPATHRAW%/model3'" %DATA%=%ROMPROVIDER%</command>
|
||||
<platform>arcade</platform>
|
||||
|
|
@ -1426,6 +1428,7 @@
|
|||
<fullname>PICO-8 Fantasy Console</fullname>
|
||||
<path>%ROMPATH%/pico8</path>
|
||||
<extension>.p8 .P8 .png .PNG</extension>
|
||||
<command label="PICO-8 (Standalone)">%EMULATOR_PICO-8% %DATA%=%ROMSAF%</command>
|
||||
<command label="Fake-08">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/fake08_libretro_android.so %EXTRA_ROM%=%ROM%</command>
|
||||
<command label="Retro8">%EMULATOR_RETROARCH% %EXTRA_CONFIGFILE%=%EXTERNALDATA%/Android/data/%ANDROIDPACKAGE%/files/retroarch.cfg %EXTRA_LIBRETRO%=%INTERNALDATA%/%ANDROIDPACKAGE%/cores/retro8_libretro_android.so %EXTRA_ROM%=%ROM%</command>
|
||||
<command label="Infinity (Standalone)">%EMULATOR_INFINITY% %ACTION%=android.intent.action.VIEW %DATA%=%ROMPROVIDER%</command>
|
||||
|
|
@ -1764,7 +1767,7 @@
|
|||
<name>steam</name>
|
||||
<fullname>Valve Steam</fullname>
|
||||
<path>%ROMPATH%/steam</path>
|
||||
<extension>.steam</extension>
|
||||
<extension>.pcgame .steam</extension>
|
||||
<command label="GameNative (Standalone)">%EMULATOR_GAMENATIVE% %ACTION%=app.gamenative.LAUNCH_GAME %EXTRAINTEGER_app_id%=%INJECT%=%ROM%</command>
|
||||
<command label="GameHub Lite (Standalone)">%EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_steamAppId%=%INJECT%=%ROM% %EXTRA_localGameId%=%INJECT%=%ROM%</command>
|
||||
<command label="GameHub Lite Local (Standalone)">%EMULATOR_GAMEHUB-LITE% %ACTION%=gamehub.lite.LAUNCH_GAME %EXTRABOOL_autoStartGame%=true %EXTRA_localGameId%=%INJECT%=%ROM%</command>
|
||||
|
|
@ -1914,8 +1917,8 @@
|
|||
<name>triforce</name>
|
||||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||
<path>%ROMPATH%/triforce</path>
|
||||
<extension>.7z .7Z .zip .ZIP</extension>
|
||||
<command>PLACEHOLDER %ROM%</command>
|
||||
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
|
||||
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% %ACTION%=android.intent.action.MAIN %CATEGORY%=android.intent.category.LEANBACK_LAUNCHER %EXTRA_AutoStartFile%=%ROMSAF%</command>
|
||||
<platform>arcade</platform>
|
||||
<theme>triforce</theme>
|
||||
</system>
|
||||
|
|
@ -2048,7 +2051,7 @@
|
|||
<name>windows</name>
|
||||
<fullname>Microsoft Windows</fullname>
|
||||
<path>%ROMPATH%/windows</path>
|
||||
<extension>.desktop .steam</extension>
|
||||
<extension>.desktop .epic .gog .pcgame .steam</extension>
|
||||
<command label="Winlator Cmod (Standalone)">%EMULATOR_WINLATOR-CMOD% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
|
||||
<command label="Winlator Cmod Glibc (Standalone)">%EMULATOR_WINLATOR-GLIBC% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
|
||||
<command label="Winlator Cmod PRoot (Standalone)">%EMULATOR_WINLATOR-PROOT% %ACTIVITY_CLEAR_TASK% %ACTIVITY_CLEAR_TOP% %EXTRA_shortcut_path%=%ROM%</command>
|
||||
|
|
@ -2121,8 +2124,8 @@
|
|||
<name>xbox</name>
|
||||
<fullname>Microsoft Xbox</fullname>
|
||||
<path>%ROMPATH%/xbox</path>
|
||||
<extension>.7z .7Z .zip .ZIP</extension>
|
||||
<command>PLACEHOLDER %ROM%</command>
|
||||
<extension>.iso .ISO .xiso .XISO</extension>
|
||||
<command label="X1 BOX (Standalone)">%EMULATOR_X1-BOX% %ACTION%=android.intent.action.VIEW %DATA%=%ROMSAF%</command>
|
||||
<platform>xbox</platform>
|
||||
<theme>xbox</theme>
|
||||
</system>
|
||||
|
|
@ -2130,8 +2133,8 @@
|
|||
<name>xbox360</name>
|
||||
<fullname>Microsoft Xbox 360</fullname>
|
||||
<path>%ROMPATH%/xbox360</path>
|
||||
<extension>.7z .7Z .zip .ZIP</extension>
|
||||
<command>PLACEHOLDER %ROM%</command>
|
||||
<extension>. .iso .ISO .xex .XEX .zar .ZAR</extension>
|
||||
<command label="aX360e (Standalone)">%EMULATOR_AX360E% %ACTION%=aenu.intent.action.AX360E %EXTRA_game_uri%=%ROMSAF%</command>
|
||||
<platform>xbox360</platform>
|
||||
<theme>xbox360</theme>
|
||||
</system>
|
||||
|
|
|
|||
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="RPCS3 Shortcut (Standalone)">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||
<command label="RPCS3 Game Serial (Standalone)">%EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3</command>
|
||||
<command label="Dolphin (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM%</command>
|
||||
<command label="Triforce (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM%</command>
|
||||
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||
|
|
@ -2141,6 +2142,7 @@
|
|||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||
<path>%ROMPATH%/triforce</path>
|
||||
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
|
||||
<command label="Dolphin (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_DOLPHIN% -b -e %ROM%</command>
|
||||
<command label="Triforce (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_TRIFORCE% -b -e %ROM%</command>
|
||||
<platform>arcade</platform>
|
||||
<theme>triforce</theme>
|
||||
|
|
@ -2373,7 +2375,7 @@
|
|||
<name>xbox</name>
|
||||
<fullname>Microsoft Xbox</fullname>
|
||||
<path>%ROMPATH%/xbox</path>
|
||||
<extension>.iso .ISO</extension>
|
||||
<extension>.iso .ISO .xiso .XISO</extension>
|
||||
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||
<platform>xbox</platform>
|
||||
<theme>xbox</theme>
|
||||
|
|
|
|||
|
|
@ -2244,7 +2244,7 @@
|
|||
<name>xbox</name>
|
||||
<fullname>Microsoft Xbox</fullname>
|
||||
<path>%ROMPATH%/xbox</path>
|
||||
<extension>.iso .ISO</extension>
|
||||
<extension>.iso .ISO .xiso .XISO</extension>
|
||||
<command label="xemu (Standalone)">%INJECT%=%BASENAME%.esprefix %EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||
<platform>xbox</platform>
|
||||
<theme>xbox</theme>
|
||||
|
|
|
|||
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="RPCS3 Shortcut (Standalone)">%RUNINBACKGROUND% %ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||
<command label="RPCS3 Game Serial (Standalone)">%EMULATOR_RPCS3% --no-gui %RPCS3_GAMEID%:%INJECT%=%BASENAME%.ps3</command>
|
||||
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% -b -e %ROM%</command>
|
||||
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
|
||||
<platform>arcade</platform>
|
||||
|
|
@ -2005,7 +2006,7 @@
|
|||
<fullname>Namco-Sega-Nintendo Triforce</fullname>
|
||||
<path>%ROMPATH%/triforce</path>
|
||||
<extension>.ciso .CISO .dff .DFF .dol .DOL .elf .ELF .gcm .GCM .gcz .GCZ .iso .ISO .json .JSON .m3u .M3U .rvz .RVZ .tgc .TGC .wad .WAD .wbfs .WBFS .wia .WIA .7z .7Z .zip .ZIP</extension>
|
||||
<command>PLACEHOLDER %ROM%</command>
|
||||
<command label="Dolphin (Standalone)">%EMULATOR_DOLPHIN% -b -e %ROM%</command>
|
||||
<platform>arcade</platform>
|
||||
<theme>triforce</theme>
|
||||
</system>
|
||||
|
|
@ -2218,7 +2219,7 @@
|
|||
<name>xbox</name>
|
||||
<fullname>Microsoft Xbox</fullname>
|
||||
<path>%ROMPATH%/xbox</path>
|
||||
<extension>.iso .ISO</extension>
|
||||
<extension>.iso .ISO .xiso .XISO</extension>
|
||||
<command label="xemu (Standalone)">%EMULATOR_XEMU% -dvd_path %ROM%</command>
|
||||
<platform>xbox</platform>
|
||||
<theme>xbox</theme>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue