feat First implementation of plugins system
feat: Added PCSX2 integration feat: Revamped UI a bit made it look better on light mode
This commit is contained in:
parent
d85268fad7
commit
a78e75335f
95 changed files with 2639 additions and 1259 deletions
7
bun.lock
7
bun.lock
|
|
@ -18,12 +18,14 @@
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
"node-disk-info": "^1.3.0",
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.10",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"systeminformation": "^5.31.1",
|
"systeminformation": "^5.31.1",
|
||||||
|
"tapable": "^2.3.0",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"ts-igdb-client": "^0.4.2",
|
"ts-igdb-client": "^0.4.2",
|
||||||
|
|
@ -48,6 +50,7 @@
|
||||||
"@tanstack/zod-adapter": "^1.162.4",
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
|
|
@ -606,6 +609,8 @@
|
||||||
|
|
||||||
"@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="],
|
"@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="],
|
||||||
|
|
||||||
|
"@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||||
|
|
||||||
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
|
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
|
||||||
|
|
@ -1210,6 +1215,8 @@
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,14 @@
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
"node-disk-info": "^1.3.0",
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.10",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"systeminformation": "^5.31.1",
|
"systeminformation": "^5.31.1",
|
||||||
|
"tapable": "^2.3.0",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"ts-igdb-client": "^0.4.2",
|
"ts-igdb-client": "^0.4.2",
|
||||||
|
|
@ -83,6 +85,7 @@
|
||||||
"@tanstack/zod-adapter": "^1.162.4",
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path, { } from "node:path";
|
import path, { } from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import app from '../package.json';
|
||||||
|
|
||||||
const system = getPlatform();
|
const system = getPlatform();
|
||||||
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
|
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
|
||||||
|
|
||||||
const compileOption: Bun.CompileBuildOptions = {
|
const compileOption: Bun.CompileBuildOptions = {
|
||||||
outfile: "gameflow",
|
outfile: "gameflow",
|
||||||
execArgv: ['--windows-hide-console'],
|
|
||||||
autoloadTsconfig: true,
|
autoloadTsconfig: true,
|
||||||
autoloadPackageJson: true,
|
autoloadPackageJson: true,
|
||||||
autoloadDotenv: true,
|
autoloadDotenv: true,
|
||||||
autoloadBunfig: true,
|
autoloadBunfig: true,
|
||||||
|
windows: {
|
||||||
|
hideConsole: true,
|
||||||
|
icon: './src/mainview/public/favicon.ico',
|
||||||
|
title: app.displayName,
|
||||||
|
description: app.description,
|
||||||
|
version: app.version
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.TARGET)
|
if (process.env.TARGET)
|
||||||
|
|
@ -63,8 +70,9 @@ await Bun.build({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
build.onEnd(async () =>
|
build.onEnd(async (b) =>
|
||||||
{
|
{
|
||||||
|
|
||||||
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
|
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
|
||||||
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
|
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
|
||||||
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
|
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,21 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import Conf from "conf";
|
import Conf from "conf";
|
||||||
import projectPackage from '~/package.json';
|
import projectPackage from '~/package.json';
|
||||||
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
|
import { SettingsSchema, SettingsType } from "@shared/constants";
|
||||||
import { client } from "@clients/romm/client.gen";
|
import { client } from "@clients/romm/client.gen";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import { login, logout } from "./auth";
|
import { login, logout } from "./auth";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { ActiveGame } from "../types/types";
|
|
||||||
import EventEmitter from "node:events";
|
import EventEmitter from "node:events";
|
||||||
import { ErrorLike } from "bun";
|
import { appPath } from "../utils";
|
||||||
import { appPath, getErrorMessage } from "../utils";
|
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import UpdateStoreJob from "./jobs/update-store";
|
import UpdateStoreJob from "./jobs/update-store";
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
|
import { PluginManager } from "./plugins/plugin-manager";
|
||||||
|
import registerPlugins from "./plugins/register-plugins";
|
||||||
|
|
||||||
export const config = new Conf<SettingsType>({
|
export const config = new Conf<SettingsType>({
|
||||||
projectName: projectPackage.name,
|
projectName: projectPackage.name,
|
||||||
|
|
@ -31,7 +31,7 @@ export const config = new Conf<SettingsType>({
|
||||||
defaults: SettingsSchema.parse({
|
defaults: SettingsSchema.parse({
|
||||||
downloadPath: path.join(os.homedir(), "gameflow"),
|
downloadPath: path.join(os.homedir(), "gameflow"),
|
||||||
windowSize: { width: 1280, height: 800 }
|
windowSize: { width: 1280, height: 800 }
|
||||||
} satisfies SettingsType),
|
}),
|
||||||
});
|
});
|
||||||
export const customEmulators = new Conf<Record<string, string>>({
|
export const customEmulators = new Conf<Record<string, string>>({
|
||||||
projectName: projectPackage.name,
|
projectName: projectPackage.name,
|
||||||
|
|
@ -64,21 +64,9 @@ export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||||
export const taskQueue = new TaskQueue();
|
export const taskQueue = new TaskQueue();
|
||||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||||
await login();
|
await login();
|
||||||
export let activeGame: ActiveGame | undefined;
|
export const plugins = new PluginManager();
|
||||||
export function setActiveGame (game: ActiveGame)
|
registerPlugins(plugins);
|
||||||
{
|
|
||||||
if (activeGame) throw new Error("Only one active game at a time");
|
|
||||||
return activeGame = game;
|
|
||||||
}
|
|
||||||
export const events = new EventEmitter<AppEventMap>();
|
export const events = new EventEmitter<AppEventMap>();
|
||||||
events.addListener('activegameexit', ({ error }) =>
|
|
||||||
{
|
|
||||||
activeGame = undefined;
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||||
|
|
||||||
|
|
@ -110,9 +98,3 @@ export async function reloadDatabase ()
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppEventMap
|
|
||||||
{
|
|
||||||
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
|
||||||
exitapp: [];
|
|
||||||
notification: [Notification];
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Drive } from "@/shared/constants";
|
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
|
import { config, db, emulatorsDb, taskQueue } from "../app";
|
||||||
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
|
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||||
import z, { number } from "zod";
|
import z from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||||
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
import { getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
import { convertLocalToFrontend, convertRomToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||||
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
|
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||||
import { createJimp } from "@jimp/core";
|
import { createJimp } from "@jimp/core";
|
||||||
import webp from "@jimp/wasm-webp";
|
import webp from "@jimp/wasm-webp";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||||
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
||||||
import { use } from "react";
|
|
||||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
|
|
||||||
// A custom jimp that supports webp
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -31,23 +31,30 @@ const Jimp = createJimp({
|
||||||
|
|
||||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||||
{
|
{
|
||||||
if (blur && !noBlur)
|
|
||||||
{
|
|
||||||
const jimp = await Jimp.read(img);
|
|
||||||
if (width)
|
|
||||||
{
|
|
||||||
jimp.resize({ w: width, h: height });
|
|
||||||
}
|
|
||||||
if (height)
|
|
||||||
{
|
|
||||||
jimp.resize({ w: width, h: height });
|
|
||||||
}
|
|
||||||
if (blur)
|
|
||||||
{
|
|
||||||
jimp.blur(blur);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jimp.getBuffer('image/png');
|
try
|
||||||
|
{
|
||||||
|
if ((blur && !noBlur) || width || height)
|
||||||
|
{
|
||||||
|
const jimp = await Jimp.read(img);
|
||||||
|
|
||||||
|
if (blur && !noBlur)
|
||||||
|
{
|
||||||
|
jimp.blur(blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width)
|
||||||
|
{
|
||||||
|
jimp.resize({ w: width, h: height });
|
||||||
|
} else if (height)
|
||||||
|
{
|
||||||
|
jimp.resize({ w: width, h: height });
|
||||||
|
}
|
||||||
|
return jimp.getBuffer('image/webp');
|
||||||
|
}
|
||||||
|
} catch (e)
|
||||||
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof img === 'string')
|
if (typeof img === 'string')
|
||||||
|
|
@ -267,7 +274,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
name: 'EMULATORJS',
|
name: 'EMULATORJS',
|
||||||
validSource: { binPath: SERVER_URL(host), type: 'js', exists: true },
|
validSource: { binPath: SERVER_URL(host), type: 'embedded', exists: true },
|
||||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||||
systems: [],
|
systems: [],
|
||||||
gameCount: 0
|
gameCount: 0
|
||||||
|
|
@ -312,11 +319,11 @@ export default new Elysia()
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
{
|
{
|
||||||
if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob))
|
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||||
{
|
{
|
||||||
if (source === 'romm' || source === 'store')
|
if (source === 'romm' || source === 'store')
|
||||||
{
|
{
|
||||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true }));
|
taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, id, { dryRun: true }));
|
||||||
return status(200);
|
return status(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,7 +366,7 @@ export default new Elysia()
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
// launch command waits for the game to exit, we don't want that.
|
// launch command waits for the game to exit, we don't want that.
|
||||||
launchCommand(validCommand, source, id, validCommands.gameId);
|
await launchCommand(validCommand, source, id, validCommands.gameId);
|
||||||
return { type: 'application', command: null };
|
return { type: 'application', command: null };
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -380,13 +387,10 @@ export default new Elysia()
|
||||||
})
|
})
|
||||||
.post("/stop", async ({ }) =>
|
.post("/stop", async ({ }) =>
|
||||||
{
|
{
|
||||||
if (activeGame)
|
const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||||
|
if (job)
|
||||||
{
|
{
|
||||||
events.emit('activegameexit', {
|
job.abort('cancel');
|
||||||
source: 'local', id: String(activeGame.gameId),
|
|
||||||
exitCode: null,
|
|
||||||
signalCode: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||||
|
|
@ -564,6 +568,9 @@ export default new Elysia()
|
||||||
if (g.platform_slug === sourceData.platform_slug)
|
if (g.platform_slug === sourceData.platform_slug)
|
||||||
rank += 1;
|
rank += 1;
|
||||||
|
|
||||||
|
if (g.id.source === 'local')
|
||||||
|
rank -= 0.2;
|
||||||
|
|
||||||
if (g.metadata)
|
if (g.metadata)
|
||||||
{
|
{
|
||||||
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
|
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRo
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
|
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
|
||||||
import { db } from "../app";
|
import { db } from "../app";
|
||||||
import { FrontEndPlatformType } from "@shared/constants";
|
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,103 +3,23 @@ import { which } from 'bun';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import * as schema from '@schema/emulators';
|
import * as schema from '@schema/emulators';
|
||||||
import * as appSchema from "@schema/app";
|
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app';
|
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { $ } from 'bun';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
|
||||||
import { CommandEntry, EmulatorSourceType } from '@/shared/constants';
|
|
||||||
import { cores } from '../../emulatorjs/emulatorjs';
|
import { cores } from '../../emulatorjs/emulatorjs';
|
||||||
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
|
|
||||||
export const varRegex = /%([^%]+)%/g;
|
export const varRegex = /%([^%]+)%/g;
|
||||||
export const assignRegex = /(%\w+%)=(\S+) /g;
|
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||||
|
|
||||||
export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number)
|
export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number)
|
||||||
{
|
{
|
||||||
if (activeGame && activeGame.process?.killed === false)
|
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||||
{
|
{
|
||||||
throw new Error(`${activeGame.name} currently running`);
|
throw new Error(`${id} currently running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localGame = await db.query.games.findFirst({
|
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
|
||||||
where: eq(appSchema.games.id, id), columns: {
|
|
||||||
name: true,
|
|
||||||
source_id: true,
|
|
||||||
source: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
|
||||||
{
|
|
||||||
const game = spawn(validCommand.command, {
|
|
||||||
shell: true,
|
|
||||||
cwd: validCommand.startDir
|
|
||||||
});
|
|
||||||
game.stdout.on('data', data => console.log(data));
|
|
||||||
game.on('close', (code) =>
|
|
||||||
{
|
|
||||||
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
|
|
||||||
resolve(code);
|
|
||||||
});
|
|
||||||
game.on('error', e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
events.emit('notification', { message: e.message, type: 'error' });
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
setActiveGame({
|
|
||||||
process: game,
|
|
||||||
name: localGame?.name ?? "Unknown",
|
|
||||||
gameId: id,
|
|
||||||
command: validCommand
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateRommProps (id: number)
|
|
||||||
{
|
|
||||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
|
||||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source === 'romm')
|
|
||||||
{
|
|
||||||
updateRommProps(Number(sourceId));
|
|
||||||
}
|
|
||||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
|
||||||
{
|
|
||||||
updateRommProps(Number(localGame.source_id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
|
||||||
|
|
||||||
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
|
||||||
const game = setActiveGame({
|
|
||||||
process: Bun.spawn({
|
|
||||||
cmd,
|
|
||||||
env: {
|
|
||||||
...process.env
|
|
||||||
},
|
|
||||||
onExit (subprocess, exitCode, signalCode, error)
|
|
||||||
{
|
|
||||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
|
||||||
},
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
}),
|
|
||||||
name: localGame?.name ?? "Unknown",
|
|
||||||
gameId: validCommand.gameId,
|
|
||||||
command: validCommand.command.command
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.process.exited;
|
|
||||||
if (game.process.exitCode && game.process.exitCode > 0)
|
|
||||||
{
|
|
||||||
return status('Internal Server Error');
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -277,11 +197,14 @@ export async function getValidLaunchCommands (data: {
|
||||||
let validExec = execs.find(e => e.exists);
|
let validExec = execs.find(e => e.exists);
|
||||||
|
|
||||||
emulator = emulatorName;
|
emulator = emulatorName;
|
||||||
return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]];
|
return [
|
||||||
|
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
|
||||||
|
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
|
||||||
|
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined]];
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = value[0].substring(1, value.length - 1);
|
const key = value[0].substring(1, value.length - 1);
|
||||||
return [[value, process.env[key]]];
|
return [[value, process.env[key]] as [string, string | undefined]];
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||||
|
|
@ -311,7 +234,13 @@ export async function getValidLaunchCommands (data: {
|
||||||
label: label ?? undefined,
|
label: label ?? undefined,
|
||||||
command: formattedCommand,
|
command: formattedCommand,
|
||||||
startDir,
|
startDir,
|
||||||
valid: !invalid, emulator
|
valid: !invalid, emulator,
|
||||||
|
emulatorSource: vars['%EMUSOURCE%'] as any,
|
||||||
|
metadata: {
|
||||||
|
romPath: staticVars['%ROM%'],
|
||||||
|
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
|
||||||
|
emulatorDir: vars['%EMUDIR%']
|
||||||
|
}
|
||||||
} satisfies CommandEntry;
|
} satisfies CommandEntry;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -328,7 +257,7 @@ export async function findExecsByName (emulatorName: string)
|
||||||
return findExecs(emulatorName, emulator);
|
return findExecs(emulatorName, emulator);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined
|
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceEntryType | undefined
|
||||||
{
|
{
|
||||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||||
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
|
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
|
||||||
|
|
@ -342,7 +271,7 @@ export function findStoreEmulatorExec (id: string, emulator?: { systempath: stri
|
||||||
|
|
||||||
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||||
{
|
{
|
||||||
const execs: EmulatorSourceType[] = [];
|
const execs: EmulatorSourceEntryType[] = [];
|
||||||
|
|
||||||
if (customEmulators.has(id))
|
if (customEmulators.has(id))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
|
import { RPC_URL, } from "@shared/constants";
|
||||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
import { config, customEmulators, db, taskQueue } from "../../app";
|
||||||
import { getValidLaunchCommands } from "./launchGameService";
|
import { getValidLaunchCommands } from "./launchGameService";
|
||||||
import * as schema from '@schema/app';
|
import * as schema from '@schema/app';
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -7,14 +7,13 @@ import { getErrorMessage } from "@/bun/utils";
|
||||||
import { getLocalGameMatch } from "./utils";
|
import { getLocalGameMatch } from "./utils";
|
||||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { ErrorLike } from "elysia/universal";
|
|
||||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||||
import { cores } from "../../emulatorjs/emulatorjs";
|
import { cores } from "../../emulatorjs/emulatorjs";
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import data from "@emulators";
|
|
||||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||||
|
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||||
|
|
||||||
class CommandSearchError extends Error
|
class CommandSearchError extends Error
|
||||||
{
|
{
|
||||||
|
|
@ -62,7 +61,10 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||||
label: "Emulator JS",
|
label: "Emulator JS",
|
||||||
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
||||||
valid: true,
|
valid: true,
|
||||||
emulator: 'EMULATORJS'
|
emulator: 'EMULATORJS',
|
||||||
|
metadata: {
|
||||||
|
romPath: gameUrl
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,19 +113,19 @@ export default function buildStatusResponse ()
|
||||||
{
|
{
|
||||||
if (data === 'cancel')
|
if (data === 'cancel')
|
||||||
{
|
{
|
||||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||||
activeTask?.abort('cancel');
|
activeTask?.abort('cancel');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async open (ws)
|
async open (ws)
|
||||||
{
|
{
|
||||||
sendLatests();
|
sendLatests();
|
||||||
|
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
|
||||||
|
|
||||||
async function sendLatests ()
|
async function sendLatests ()
|
||||||
{
|
{
|
||||||
if (ws.readyState > 1) return;
|
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(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||||
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
|
|
||||||
if (activeTask)
|
if (activeTask)
|
||||||
{
|
{
|
||||||
if (activeTask.status === 'queued')
|
if (activeTask.status === 'queued')
|
||||||
|
|
@ -134,7 +136,7 @@ export default function buildStatusResponse ()
|
||||||
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
} else if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||||
{
|
{
|
||||||
ws.send({ status: 'playing', details: 'Playing' });
|
ws.send({ status: 'playing', details: 'Playing' });
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +191,7 @@ export default function buildStatusResponse ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispose: Function[] = [];
|
const dispose: Function[] = [];
|
||||||
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
const handleActiveExit = async (data: { error?: unknown; }) =>
|
||||||
{
|
{
|
||||||
if (data.error)
|
if (data.error)
|
||||||
{
|
{
|
||||||
|
|
@ -200,38 +202,41 @@ export default function buildStatusResponse ()
|
||||||
}
|
}
|
||||||
await sendLatests();
|
await sendLatests();
|
||||||
};
|
};
|
||||||
events.on('activegameexit', handleActiveExit);
|
|
||||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
|
||||||
dispose.push(taskQueue.on('progress', (data) =>
|
dispose.push(taskQueue.on('progress', (data) =>
|
||||||
{
|
{
|
||||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
|
|
||||||
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
|
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('queued', (data) =>
|
dispose.push(taskQueue.on('queued', (data) =>
|
||||||
{
|
{
|
||||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
ws.send({ status: 'queued' });
|
ws.send({ status: 'queued' });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('completed', (data) =>
|
dispose.push(taskQueue.on('ended', (data) =>
|
||||||
{
|
{
|
||||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
ws.send({ status: 'refresh' });
|
ws.send({ status: 'refresh' });
|
||||||
|
} else if (data.job.job instanceof LaunchGameJob)
|
||||||
|
{
|
||||||
|
handleActiveExit({});
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('error', (data) =>
|
dispose.push(taskQueue.on('error', (data) =>
|
||||||
{
|
{
|
||||||
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
ws.send({
|
ws.send({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: getErrorMessage(data.error)
|
error: getErrorMessage(data.error)
|
||||||
});
|
});
|
||||||
|
} else if (data.job.job instanceof LaunchGameJob)
|
||||||
|
{
|
||||||
|
handleActiveExit({ error: data.error });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import path from "node:path";
|
||||||
import { config, db, emulatorsDb } from "../../app";
|
import { config, db, emulatorsDb } from "../../app";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
|
import { StoreGameType } from "@shared/constants";
|
||||||
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import romm from "@/mainview/scripts/queries/romm";
|
|
||||||
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
||||||
|
import { isSteamDeck, isSteamDeckGameMode } from "@/bun/utils";
|
||||||
|
|
||||||
export async function calculateSize (installPath: string | null)
|
export async function calculateSize (installPath: string | null)
|
||||||
{
|
{
|
||||||
|
|
@ -29,9 +29,10 @@ export function getLocalGameMatch (id: string, source: string)
|
||||||
|
|
||||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||||
{
|
{
|
||||||
|
const steamDeck = isSteamDeckGameMode();
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
id: { id: String(rom.id), source: 'romm' },
|
id: { id: String(rom.id), source: 'romm' },
|
||||||
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
|
path_cover: `/api/romm/image/romm${steamDeck ? rom.path_cover_small : rom.path_cover_large}`,
|
||||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||||
updated_at: new Date(rom.updated_at),
|
updated_at: new Date(rom.updated_at),
|
||||||
slug: rom.slug,
|
slug: rom.slug,
|
||||||
|
|
|
||||||
6
src/bun/api/hooks/app.ts
Normal file
6
src/bun/api/hooks/app.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { GameHooks } from "./emulators";
|
||||||
|
|
||||||
|
export class GameflowHooks
|
||||||
|
{
|
||||||
|
games = new GameHooks();
|
||||||
|
}
|
||||||
21
src/bun/api/hooks/emulators.ts
Normal file
21
src/bun/api/hooks/emulators.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook } from 'tapable';
|
||||||
|
|
||||||
|
export class GameHooks
|
||||||
|
{
|
||||||
|
/** override the launch command for an emulator
|
||||||
|
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
||||||
|
* @param ctx.emulator The emulator ID if any
|
||||||
|
* @param ctx.game.source The source of the game
|
||||||
|
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
|
||||||
|
* @returns The argument list to be used when running the emulator.
|
||||||
|
* If no emulator bin in the command entry is found the actual command will be used as the bin.
|
||||||
|
*/
|
||||||
|
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
autoValidCommand: CommandEntry;
|
||||||
|
game: {
|
||||||
|
source: string;
|
||||||
|
sourceId: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}], string[] | undefined>(['ctx']);
|
||||||
|
}
|
||||||
85
src/bun/api/jobs/bios-download-job.ts
Normal file
85
src/bun/api/jobs/bios-download-job.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||||
|
import { config } from "../app";
|
||||||
|
import { getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||||
|
import { Downloader, FileEntry } from "@/bun/utils/downloader";
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
|
|
||||||
|
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
|
||||||
|
{
|
||||||
|
static id = "bios-download-job" as const;
|
||||||
|
static dataSchema = z.object({ emulator: z.string() });
|
||||||
|
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||||
|
group: string = "bios-download";
|
||||||
|
emulator: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
|
||||||
|
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||||
|
{
|
||||||
|
this.emulator = emulator;
|
||||||
|
this.dryRun = init?.dryRun ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<never, "download">, never, "download">)
|
||||||
|
{
|
||||||
|
const allRommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data);
|
||||||
|
|
||||||
|
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||||
|
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||||
|
|
||||||
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
|
|
||||||
|
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||||
|
await ensureDir(biosFolder);
|
||||||
|
const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r);
|
||||||
|
|
||||||
|
const firmwaresToDownload: FileEntry[] = [];
|
||||||
|
|
||||||
|
for (const rommPlatform of rommPlatforms)
|
||||||
|
{
|
||||||
|
const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data);
|
||||||
|
if (firmwares)
|
||||||
|
{
|
||||||
|
for (const firmware of firmwares)
|
||||||
|
{
|
||||||
|
const firmwarePath = path.join(biosFolder, firmware.file_name);
|
||||||
|
const exists = await fs.exists(firmwarePath);
|
||||||
|
|
||||||
|
if (exists && await hashFile(firmwarePath, 'sha1'))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
firmwaresToDownload.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dryRun)
|
||||||
|
{
|
||||||
|
await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const downloader = new Downloader('bios-download', firmwaresToDownload, biosFolder, {
|
||||||
|
signal: context.abortSignal,
|
||||||
|
onProgress (stats)
|
||||||
|
{
|
||||||
|
context.setProgress(stats.progress, "download");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloader.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return { emulator: this.emulator };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import _7z from '7zip-min';
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import { move } from "fs-extra";
|
import { move } from "fs-extra";
|
||||||
|
import { simulateProgress } from "@/bun/utils";
|
||||||
|
|
||||||
type EmulatorDownloadStates = "download" | "extract";
|
type EmulatorDownloadStates = "download" | "extract";
|
||||||
|
|
||||||
|
|
@ -20,11 +21,13 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
||||||
emulator: string;
|
emulator: string;
|
||||||
downloadSource: string;
|
downloadSource: string;
|
||||||
emulatorPackage?: EmulatorPackageType;
|
emulatorPackage?: EmulatorPackageType;
|
||||||
|
dryRun?: boolean;
|
||||||
|
|
||||||
constructor(emulator: string, downloadSource: string)
|
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; })
|
||||||
{
|
{
|
||||||
this.emulator = emulator;
|
this.emulator = emulator;
|
||||||
this.downloadSource = downloadSource;
|
this.downloadSource = downloadSource;
|
||||||
|
this.dryRun = init?.dryRun ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||||
|
|
@ -56,44 +59,53 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
||||||
throw new Error("Invalid Download Type");
|
throw new Error("Invalid Download Type");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
if (this.dryRun)
|
||||||
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)
|
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
|
||||||
{
|
await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal);
|
||||||
if (await downloader.start() && destinationPaths[0])
|
} else
|
||||||
|
{
|
||||||
|
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,
|
||||||
{
|
{
|
||||||
let destinationPath = destinationPaths[0];
|
signal: context.abortSignal,
|
||||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
onProgress (stats)
|
||||||
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]));
|
context.setProgress(stats.progress, 'download');
|
||||||
if (stat.isDirectory())
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
console.log("Found 1 root folder, using that instead");
|
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
if (stat.isDirectory())
|
||||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
{
|
||||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
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 ()
|
exposeData ()
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ import * as schema from "@schema/app";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||||
import { config, db, emulatorsDb, events, jar } from "../app";
|
import { config, db, emulatorsDb, events } from "../app";
|
||||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||||
import * as igdb from 'ts-igdb-client';
|
import * as igdb from 'ts-igdb-client';
|
||||||
import secrets from "../secrets";
|
import secrets from "../secrets";
|
||||||
import { hashFile } from "@/bun/utils";
|
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import { sleep } from "bun";
|
|
||||||
import _7z from '7zip-min';
|
import _7z from '7zip-min';
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
|
|
@ -25,11 +25,14 @@ export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
||||||
export class InstallJob implements IJob<never, InstallJobStates>
|
export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
{
|
{
|
||||||
|
static id = "install-job" as const;
|
||||||
|
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||||
|
static dataSchema = z.never();
|
||||||
public gameId: string;
|
public gameId: string;
|
||||||
public source: string;
|
public source: string;
|
||||||
public sourceId: string;
|
public sourceId: string;
|
||||||
public config?: JobConfig;
|
public config?: JobConfig;
|
||||||
static id = "install-job" as const;
|
|
||||||
public group = InstallJob.id;
|
public group = InstallJob.id;
|
||||||
|
|
||||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||||
|
|
@ -53,7 +56,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
file_name: string;
|
file_name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
let cookie: string = '';
|
|
||||||
let screenshotUrls: string[];
|
let screenshotUrls: string[];
|
||||||
let coverUrl: string;
|
let coverUrl: string;
|
||||||
let rommPlatform: PlatformSchema | undefined;
|
let rommPlatform: PlatformSchema | undefined;
|
||||||
|
|
@ -115,7 +117,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
}));
|
}));
|
||||||
|
|
||||||
files.push(...rommFiles.filter(f => f !== undefined));
|
files.push(...rommFiles.filter(f => f !== undefined));
|
||||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
|
||||||
break;
|
break;
|
||||||
case 'store':
|
case 'store':
|
||||||
const game = await getStoreGameFromId(this.gameId);
|
const game = await getStoreGameFromId(this.gameId);
|
||||||
|
|
@ -295,12 +296,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
for (let i = 0; i < 10; i++)
|
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
||||||
{
|
|
||||||
cx.setProgress(i * 10, "download");
|
|
||||||
if (cx.abortSignal.aborted) return;
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
import z, { _ZodType } from "zod";
|
||||||
import { taskQueue } from "../app";
|
import { taskQueue } from "../app";
|
||||||
import { LoginJob } from "./login-job";
|
import { LoginJob } from "./login-job";
|
||||||
import TwitchLoginJob from "./twitch-login-job";
|
import TwitchLoginJob from "./twitch-login-job";
|
||||||
|
|
@ -7,22 +7,27 @@ import UpdateStoreJob from "./update-store";
|
||||||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { IJob } from "../task-queue";
|
import { IJob } from "../task-queue";
|
||||||
|
import { LaunchGameJob } from "./launch-game-job";
|
||||||
|
import { BiosDownloadJob } from "./bios-download-job";
|
||||||
|
import { InstallJob } from "./install-job";
|
||||||
|
|
||||||
function registerJob<
|
function registerJob<
|
||||||
const Path extends string,
|
const Path extends string,
|
||||||
const Schema extends ZodTypeAny,
|
const Schema extends z.ZodTypeAny,
|
||||||
|
const Query extends z.ZodTypeAny,
|
||||||
const States extends string,
|
const States extends string,
|
||||||
T extends IJob<z.infer<Schema>, States>
|
T extends IJob<z.infer<Schema>, States>
|
||||||
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
|
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
|
||||||
{
|
{
|
||||||
return new Elysia().ws(_job.id, {
|
return new Elysia().ws(_job.id, {
|
||||||
body: z.discriminatedUnion('type', [
|
body: z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('cancel') })
|
z.object({ type: z.literal('cancel') })
|
||||||
]),
|
]),
|
||||||
|
query: z.record(z.string(), z.any()),
|
||||||
response: z.discriminatedUnion('type', [
|
response: z.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal(['data', 'started', 'progress']),
|
type: z.literal(['data', 'started', 'progress']),
|
||||||
status: z.string(),
|
state: z.string().optional(),
|
||||||
progress: z.number(),
|
progress: z.number(),
|
||||||
data: _job.dataSchema
|
data: _job.dataSchema
|
||||||
}),
|
}),
|
||||||
|
|
@ -31,44 +36,45 @@ function registerJob<
|
||||||
]),
|
]),
|
||||||
open (ws)
|
open (ws)
|
||||||
{
|
{
|
||||||
const job = taskQueue.findJob(_job.id, _job);
|
const jobId = (_job.query ? _job.query(ws.data.query) : _job.id);
|
||||||
|
const job = taskQueue.findJob(jobId, _job);
|
||||||
if (job)
|
if (job)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
|
|
||||||
(ws.data as any).cleanup = [
|
(ws.data as any).cleanup = [
|
||||||
taskQueue.on('started', ({ id, job }) =>
|
taskQueue.on('started', ({ id, job }) =>
|
||||||
{
|
{
|
||||||
if (id === _job.id)
|
if (id === jobId)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
taskQueue.on('progress', ({ id, job }) =>
|
taskQueue.on('progress', ({ id, job }) =>
|
||||||
{
|
{
|
||||||
if (id === _job.id)
|
if (id === jobId)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
taskQueue.on('completed', ({ id, job }) =>
|
taskQueue.on('completed', ({ id, job }) =>
|
||||||
{
|
{
|
||||||
if (id === _job.id)
|
if (id === jobId)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
taskQueue.on('ended', ({ id, job }) =>
|
taskQueue.on('ended', ({ id, job }) =>
|
||||||
{
|
{
|
||||||
if (id === _job.id)
|
if (id === jobId)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
taskQueue.on('error', ({ id, error }) =>
|
taskQueue.on('error', ({ id, error }) =>
|
||||||
{
|
{
|
||||||
if (id === _job.id)
|
if (id === jobId)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'error', error: getErrorMessage(error) });
|
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +89,8 @@ function registerJob<
|
||||||
{
|
{
|
||||||
if (message.type === 'cancel')
|
if (message.type === 'cancel')
|
||||||
{
|
{
|
||||||
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
const jobId = (_job.query ? _job.query(this.query) : _job.id);
|
||||||
|
taskQueue.findJob(jobId, _job)?.abort('cancel');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -93,4 +100,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
.use(registerJob(LoginJob))
|
.use(registerJob(LoginJob))
|
||||||
.use(registerJob(TwitchLoginJob))
|
.use(registerJob(TwitchLoginJob))
|
||||||
.use(registerJob(UpdateStoreJob))
|
.use(registerJob(UpdateStoreJob))
|
||||||
|
.use(registerJob(LaunchGameJob))
|
||||||
|
.use(registerJob(BiosDownloadJob))
|
||||||
|
.use(registerJob(InstallJob))
|
||||||
.use(registerJob(EmulatorDownloadJob));
|
.use(registerJob(EmulatorDownloadJob));
|
||||||
|
|
|
||||||
121
src/bun/api/jobs/launch-game-job.ts
Normal file
121
src/bun/api/jobs/launch-game-job.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||||
|
import { db, events, plugins } from "../app";
|
||||||
|
import * as appSchema from "@schema/app";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||||
|
|
||||||
|
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||||
|
{
|
||||||
|
static id = "launch-game" as const;
|
||||||
|
static dataSchema = z.optional(ActiveGameSchema);
|
||||||
|
group = "launch-game";
|
||||||
|
activeGame?: ActiveGameType;
|
||||||
|
gameId: number;
|
||||||
|
validCommand: CommandEntry;
|
||||||
|
gameSource: string;
|
||||||
|
gameSourceId: string;
|
||||||
|
|
||||||
|
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string)
|
||||||
|
{
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.validCommand = validCommand;
|
||||||
|
this.gameSource = source;
|
||||||
|
this.gameSourceId = sourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<ActiveGameType, "playing">, ActiveGameType, "playing">)
|
||||||
|
{
|
||||||
|
const localGame = await db.query.games.findFirst({
|
||||||
|
where: eq(appSchema.games.id, this.gameId), columns: {
|
||||||
|
name: true,
|
||||||
|
source_id: true,
|
||||||
|
source: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||||
|
autoValidCommand: this.validCommand,
|
||||||
|
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }
|
||||||
|
});
|
||||||
|
const command = commandArgs ? this.validCommand.metadata.emulatorBin ?? this.validCommand.command : this.validCommand.command;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
const game = spawn(command, commandArgs, {
|
||||||
|
shell: true,
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
game.stdout.on('data', data => console.log(data));
|
||||||
|
game.on('close', (code) =>
|
||||||
|
{
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
game.on('error', e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeGame = {
|
||||||
|
process: game,
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: this.gameId,
|
||||||
|
command: this.validCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateRommProps (id: number)
|
||||||
|
{
|
||||||
|
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||||
|
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gameSource === 'romm')
|
||||||
|
{
|
||||||
|
updateRommProps(Number(this.gameSourceId));
|
||||||
|
}
|
||||||
|
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||||
|
{
|
||||||
|
updateRommProps(Number(localGame.source_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||||
|
|
||||||
|
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||||
|
const game = setActiveGame({
|
||||||
|
process: Bun.spawn({
|
||||||
|
cmd,
|
||||||
|
env: {
|
||||||
|
...process.env
|
||||||
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
}),
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: validCommand.gameId,
|
||||||
|
command: validCommand.command.command
|
||||||
|
});
|
||||||
|
|
||||||
|
await game.process.exited;
|
||||||
|
if (game.process.exitCode && game.process.exitCode > 0)
|
||||||
|
{
|
||||||
|
return status('Internal Server Error');
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return this.activeGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||||
import { host, localIp } from "@/bun/utils/host";
|
import { host, localIp } from "@/bun/utils/host";
|
||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { getStoreRootFolder } from "../store/services/gamesService";
|
||||||
import { STORE_VERSION } from "@/shared/constants";
|
import { STORE_VERSION } from "@/shared/constants";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export default class UpdateStoreJob implements IJob<never, never>
|
export default class UpdateStoreJob implements IJob<never, never>
|
||||||
{
|
{
|
||||||
static id = "update-store" as const;
|
static id = "update-store" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
packageName: string;
|
packageName: string;
|
||||||
registry: URL;
|
registry: URL;
|
||||||
storeVersion: string;
|
storeVersion: string;
|
||||||
|
|
@ -27,7 +29,8 @@ export default class UpdateStoreJob implements IJob<never, never>
|
||||||
const storeFolder = getStoreRootFolder();
|
const storeFolder = getStoreRootFolder();
|
||||||
await ensureDir(storeFolder);
|
await ensureDir(storeFolder);
|
||||||
|
|
||||||
await Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
console.log("Updating Store");
|
||||||
|
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
|
||||||
cwd: storeFolder,
|
cwd: storeFolder,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
|
@ -35,6 +38,13 @@ export default class UpdateStoreJob implements IJob<never, never>
|
||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
BUN_INSTALL_CACHE_DIR: tempCache
|
BUN_INSTALL_CACHE_DIR: tempCache
|
||||||
}
|
}
|
||||||
}).exited;
|
});
|
||||||
|
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
console.log(stdout);
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
if (stderr)
|
||||||
|
console.error(stderr);
|
||||||
|
await proc.exited;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Notification } from '@shared/constants';
|
|
||||||
import { events } from './app';
|
import { events } from './app';
|
||||||
|
|
||||||
export default function buildNotificationsStream ()
|
export default function buildNotificationsStream ()
|
||||||
|
|
@ -10,7 +10,7 @@ export default function buildNotificationsStream ()
|
||||||
{
|
{
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
function enqueue (data: Notification, event?: 'notification')
|
function enqueue (data: FrontendNotification, event?: 'notification')
|
||||||
{
|
{
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
const evntString = event ? `event: ${event}\n` : '';
|
||||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||||
|
|
@ -30,7 +30,7 @@ export default function buildNotificationsStream ()
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
const notificationHandler = (notification: Notification) =>
|
const notificationHandler = (notification: FrontendNotification) =>
|
||||||
{
|
{
|
||||||
enqueue(notification, 'notification');
|
enqueue(notification, 'notification');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,493 @@
|
||||||
|
[UI]
|
||||||
|
SettingsVersion = 1
|
||||||
|
InhibitScreensaver = true
|
||||||
|
ConfirmShutdown = true
|
||||||
|
StartPaused = false
|
||||||
|
PauseOnFocusLoss = false
|
||||||
|
StartFullscreen = false
|
||||||
|
DoubleClickTogglesFullscreen = true
|
||||||
|
HideMouseCursor = false
|
||||||
|
RenderToSeparateWindow = false
|
||||||
|
HideMainWindowWhenRunning = false
|
||||||
|
DisableWindowResize = false
|
||||||
|
Theme = darkfusion
|
||||||
|
SetupWizardIncomplete = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore]
|
||||||
|
CdvdVerboseReads = false
|
||||||
|
CdvdDumpBlocks = false
|
||||||
|
CdvdShareWrite = false
|
||||||
|
EnablePatches = true
|
||||||
|
EnableCheats = false
|
||||||
|
EnablePINE = false
|
||||||
|
EnableWideScreenPatches = false
|
||||||
|
EnableNoInterlacingPatches = false
|
||||||
|
EnableRecordingTools = true
|
||||||
|
EnableGameFixes = true
|
||||||
|
SaveStateOnShutdown = false
|
||||||
|
EnableDiscordPresence = false
|
||||||
|
InhibitScreensaver = true
|
||||||
|
ConsoleToStdio = false
|
||||||
|
HostFs = false
|
||||||
|
BackupSavestate = true
|
||||||
|
SavestateZstdCompression = true
|
||||||
|
McdEnableEjection = true
|
||||||
|
McdFolderAutoManage = true
|
||||||
|
WarnAboutUnsafeSettings = true
|
||||||
|
GzipIsoIndexTemplate = $(f).pindex.tmp
|
||||||
|
BlockDumpSaveDirectory =
|
||||||
|
EnableFastBoot = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Speedhacks]
|
||||||
|
EECycleRate = 0
|
||||||
|
EECycleSkip = 0
|
||||||
|
fastCDVD = false
|
||||||
|
IntcStat = true
|
||||||
|
WaitLoop = true
|
||||||
|
vuFlagHack = true
|
||||||
|
vuThread = true
|
||||||
|
vu1Instant = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/CPU]
|
||||||
|
FPU.DenormalsAreZero = true
|
||||||
|
FPU.FlushToZero = true
|
||||||
|
FPU.Roundmode = 3
|
||||||
|
AffinityControlMode = 0
|
||||||
|
VU0.DenormalsAreZero = true
|
||||||
|
VU0.FlushToZero = true
|
||||||
|
VU0.Roundmode = 3
|
||||||
|
VU1.DenormalsAreZero = true
|
||||||
|
VU1.FlushToZero = true
|
||||||
|
VU1.Roundmode = 3
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/CPU/Recompiler]
|
||||||
|
EnableEE = true
|
||||||
|
EnableIOP = true
|
||||||
|
EnableEECache = false
|
||||||
|
EnableVU0 = true
|
||||||
|
EnableVU1 = true
|
||||||
|
EnableFastmem = true
|
||||||
|
PauseOnTLBMiss = false
|
||||||
|
vu0Overflow = true
|
||||||
|
vu0ExtraOverflow = false
|
||||||
|
vu0SignOverflow = false
|
||||||
|
vu0Underflow = false
|
||||||
|
vu1Overflow = true
|
||||||
|
vu1ExtraOverflow = false
|
||||||
|
vu1SignOverflow = false
|
||||||
|
vu1Underflow = false
|
||||||
|
fpuOverflow = true
|
||||||
|
fpuExtraOverflow = false
|
||||||
|
fpuFullMode = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/GS]
|
||||||
|
VsyncQueueSize = 2
|
||||||
|
FrameLimitEnable = true
|
||||||
|
VsyncEnable = 0
|
||||||
|
FramerateNTSC = 59.94
|
||||||
|
FrameratePAL = 50
|
||||||
|
SyncToHostRefreshRate = false
|
||||||
|
AspectRatio = Auto 4:3/3:2
|
||||||
|
FMVAspectRatioSwitch = Off
|
||||||
|
ScreenshotSize = 0
|
||||||
|
ScreenshotFormat = 0
|
||||||
|
ScreenshotQuality = 50
|
||||||
|
StretchY = 100
|
||||||
|
CropLeft = 0
|
||||||
|
CropTop = 0
|
||||||
|
CropRight = 0
|
||||||
|
CropBottom = 0
|
||||||
|
pcrtc_antiblur = true
|
||||||
|
disable_interlace_offset = false
|
||||||
|
pcrtc_offsets = false
|
||||||
|
pcrtc_overscan = false
|
||||||
|
IntegerScaling = false
|
||||||
|
UseDebugDevice = false
|
||||||
|
UseBlitSwapChain = false
|
||||||
|
disable_shader_cache = false
|
||||||
|
DisableDualSourceBlend = false
|
||||||
|
DisableFramebufferFetch = false
|
||||||
|
DisableThreadedPresentation = false
|
||||||
|
SkipDuplicateFrames = false
|
||||||
|
OsdShowMessages = true
|
||||||
|
OsdShowSpeed = false
|
||||||
|
OsdShowFPS = false
|
||||||
|
OsdShowCPU = false
|
||||||
|
OsdShowGPU = false
|
||||||
|
OsdShowResolution = false
|
||||||
|
OsdShowGSStats = false
|
||||||
|
OsdShowIndicators = true
|
||||||
|
OsdShowSettings = false
|
||||||
|
OsdShowInputs = false
|
||||||
|
OsdShowFrameTimes = false
|
||||||
|
HWSpinGPUForReadbacks = false
|
||||||
|
HWSpinCPUForReadbacks = false
|
||||||
|
paltex = false
|
||||||
|
autoflush_sw = true
|
||||||
|
preload_frame_with_gs_data = false
|
||||||
|
mipmap = true
|
||||||
|
UserHacks = false
|
||||||
|
UserHacks_align_sprite_X = false
|
||||||
|
UserHacks_AutoFlush = false
|
||||||
|
UserHacks_CPU_FB_Conversion = false
|
||||||
|
UserHacks_ReadTCOnClose = false
|
||||||
|
UserHacks_DisableDepthSupport = false
|
||||||
|
UserHacks_DisablePartialInvalidation = false
|
||||||
|
UserHacks_Disable_Safe_Features = false
|
||||||
|
UserHacks_merge_pp_sprite = false
|
||||||
|
UserHacks_WildHack = false
|
||||||
|
UserHacks_TextureInsideRt = 0
|
||||||
|
UserHacks_TargetPartialInvalidation = false
|
||||||
|
UserHacks_EstimateTextureRegion = false
|
||||||
|
fxaa = false
|
||||||
|
ShadeBoost = false
|
||||||
|
dump = false
|
||||||
|
save = false
|
||||||
|
savef = false
|
||||||
|
savet = false
|
||||||
|
savez = false
|
||||||
|
DumpReplaceableTextures = false
|
||||||
|
DumpReplaceableMipmaps = false
|
||||||
|
DumpTexturesWithFMVActive = false
|
||||||
|
DumpDirectTextures = true
|
||||||
|
DumpPaletteTextures = true
|
||||||
|
LoadTextureReplacements = false
|
||||||
|
LoadTextureReplacementsAsync = true
|
||||||
|
PrecacheTextureReplacements = false
|
||||||
|
EnableVideoCapture = true
|
||||||
|
EnableVideoCaptureParameters = false
|
||||||
|
VideoCaptureAutoResolution = false
|
||||||
|
EnableAudioCapture = true
|
||||||
|
EnableAudioCaptureParameters = false
|
||||||
|
linear_present_mode = 1
|
||||||
|
deinterlace_mode = 0
|
||||||
|
OsdScale = 100
|
||||||
|
Renderer = 14
|
||||||
|
upscale_multiplier = 1
|
||||||
|
mipmap_hw = -1
|
||||||
|
accurate_blending_unit = 1
|
||||||
|
crc_hack_level = -1
|
||||||
|
filter = 2
|
||||||
|
texture_preloading = 2
|
||||||
|
GSDumpCompression = 2
|
||||||
|
HWDownloadMode = 0
|
||||||
|
CASMode = 0
|
||||||
|
CASSharpness = 50
|
||||||
|
dithering_ps2 = 2
|
||||||
|
MaxAnisotropy = 0
|
||||||
|
extrathreads = 3
|
||||||
|
extrathreads_height = 4
|
||||||
|
TVShader = 0
|
||||||
|
UserHacks_SkipDraw_Start = 0
|
||||||
|
UserHacks_SkipDraw_End = 0
|
||||||
|
UserHacks_Half_Bottom_Override = -1
|
||||||
|
UserHacks_HalfPixelOffset = 0
|
||||||
|
UserHacks_round_sprite_offset = 0
|
||||||
|
UserHacks_TCOffsetX = 0
|
||||||
|
UserHacks_TCOffsetY = 0
|
||||||
|
UserHacks_CPUSpriteRenderBW = 0
|
||||||
|
UserHacks_CPUCLUTRender = 0
|
||||||
|
UserHacks_GPUTargetCLUTMode = 0
|
||||||
|
TriFilter = -1
|
||||||
|
OverrideTextureBarriers = -1
|
||||||
|
OverrideGeometryShaders = -1
|
||||||
|
ShadeBoost_Brightness = 50
|
||||||
|
ShadeBoost_Contrast = 50
|
||||||
|
ShadeBoost_Saturation = 50
|
||||||
|
png_compression_level = 1
|
||||||
|
saven = 0
|
||||||
|
savel = 5000
|
||||||
|
CaptureContainer = mp4
|
||||||
|
VideoCaptureCodec =
|
||||||
|
VideoCaptureParameters =
|
||||||
|
AudioCaptureCodec =
|
||||||
|
AudioCaptureParameters =
|
||||||
|
VideoCaptureBitrate = 6000
|
||||||
|
VideoCaptureWidth = 640
|
||||||
|
VideoCaptureHeight = 480
|
||||||
|
AudioCaptureBitrate = 160
|
||||||
|
Adapter = (Default)
|
||||||
|
HWDumpDirectory =
|
||||||
|
SWDumpDirectory =
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Debug]
|
||||||
|
Global_Enable = false
|
||||||
|
Show_Messages = false
|
||||||
|
Show_Messages_Key_On_Off = false
|
||||||
|
Show_Messages_Voice_Off = false
|
||||||
|
Show_Messages_DMA_Transfer = false
|
||||||
|
Show_Messages_AutoDMA = false
|
||||||
|
Show_Messages_Overruns = false
|
||||||
|
Show_Messages_CacheStats = false
|
||||||
|
Log_Register_Access = false
|
||||||
|
Log_DMA_Transfers = false
|
||||||
|
Log_WAVE_Output = false
|
||||||
|
Dump_Info = false
|
||||||
|
Dump_Memory = false
|
||||||
|
Dump_Regs = false
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Mixing]
|
||||||
|
FinalVolume = 100
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Output]
|
||||||
|
OutputModule = cubeb
|
||||||
|
BackendName =
|
||||||
|
DeviceName =
|
||||||
|
Latency = 60
|
||||||
|
OutputLatency = 20
|
||||||
|
OutputLatencyMinimal = false
|
||||||
|
SynchMode = 0
|
||||||
|
SpeakerConfiguration = 0
|
||||||
|
DplDecodingLevel = 0
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Eth]
|
||||||
|
EthEnable = false
|
||||||
|
EthApi = Unset
|
||||||
|
EthDevice =
|
||||||
|
EthLogDNS = false
|
||||||
|
InterceptDHCP = false
|
||||||
|
PS2IP = 0.0.0.0
|
||||||
|
Mask = 0.0.0.0
|
||||||
|
Gateway = 0.0.0.0
|
||||||
|
DNS1 = 0.0.0.0
|
||||||
|
DNS2 = 0.0.0.0
|
||||||
|
AutoMask = true
|
||||||
|
AutoGateway = true
|
||||||
|
ModeDNS1 = Auto
|
||||||
|
ModeDNS2 = Auto
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Eth/Hosts]
|
||||||
|
Count = 0
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Hdd]
|
||||||
|
HddEnable = false
|
||||||
|
HddFile = DEV9hdd.raw
|
||||||
|
HddSizeSectors = 83886080
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Gamefixes]
|
||||||
|
VuAddSubHack = false
|
||||||
|
FpuMulHack = false
|
||||||
|
FpuNegDivHack = false
|
||||||
|
XgKickHack = false
|
||||||
|
EETimingHack = false
|
||||||
|
InstantDMAHack = false
|
||||||
|
SoftwareRendererFMVHack = false
|
||||||
|
SkipMPEGHack = false
|
||||||
|
OPHFlagHack = false
|
||||||
|
DMABusyHack = false
|
||||||
|
VIFFIFOHack = false
|
||||||
|
VIF1StallHack = false
|
||||||
|
GIFFIFOHack = false
|
||||||
|
GoemonTlbHack = false
|
||||||
|
IbitHack = false
|
||||||
|
VUSyncHack = false
|
||||||
|
VUOverflowHack = false
|
||||||
|
BlitInternalFPSHack = false
|
||||||
|
FullVU0SyncHack = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Profiler]
|
||||||
|
Enabled = false
|
||||||
|
RecBlocks_EE = true
|
||||||
|
RecBlocks_IOP = true
|
||||||
|
RecBlocks_VU0 = true
|
||||||
|
RecBlocks_VU1 = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Debugger]
|
||||||
|
ShowDebuggerOnStart = false
|
||||||
|
AlignMemoryWindowStart = true
|
||||||
|
FontWidth = 8
|
||||||
|
FontHeight = 12
|
||||||
|
WindowWidth = 0
|
||||||
|
WindowHeight = 0
|
||||||
|
MemoryViewBytesPerRow = 16
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/TraceLog]
|
||||||
|
Enabled = false
|
||||||
|
EE.bitset = 0
|
||||||
|
IOP.bitset = 0
|
||||||
|
|
||||||
|
|
||||||
|
[USB1]
|
||||||
|
Type = None
|
||||||
|
|
||||||
|
|
||||||
|
[USB2]
|
||||||
|
Type = None
|
||||||
|
|
||||||
|
|
||||||
|
[Achievements]
|
||||||
|
Enabled = false
|
||||||
|
TestMode = false
|
||||||
|
UnofficialTestMode = false
|
||||||
|
RichPresence = true
|
||||||
|
ChallengeMode = false
|
||||||
|
Leaderboards = true
|
||||||
|
Notifications = true
|
||||||
|
SoundEffects = true
|
||||||
|
PrimedIndicators = true
|
||||||
|
|
||||||
|
|
||||||
|
[Filenames]
|
||||||
|
BIOS =
|
||||||
|
|
||||||
|
|
||||||
|
[Framerate]
|
||||||
|
NominalScalar = 1
|
||||||
|
TurboScalar = 2
|
||||||
|
SlomoScalar = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
[MemoryCards]
|
||||||
|
Slot1_Enable = true
|
||||||
|
Slot1_Filename = Mcd001.ps2
|
||||||
|
Slot2_Enable = true
|
||||||
|
Slot2_Filename = Mcd002.ps2
|
||||||
|
Multitap1_Slot2_Enable = false
|
||||||
|
Multitap1_Slot2_Filename = Mcd-Multitap1-Slot02.ps2
|
||||||
|
Multitap1_Slot3_Enable = false
|
||||||
|
Multitap1_Slot3_Filename = Mcd-Multitap1-Slot03.ps2
|
||||||
|
Multitap1_Slot4_Enable = false
|
||||||
|
Multitap1_Slot4_Filename = Mcd-Multitap1-Slot04.ps2
|
||||||
|
Multitap2_Slot2_Enable = false
|
||||||
|
Multitap2_Slot2_Filename = Mcd-Multitap2-Slot02.ps2
|
||||||
|
Multitap2_Slot3_Enable = false
|
||||||
|
Multitap2_Slot3_Filename = Mcd-Multitap2-Slot03.ps2
|
||||||
|
Multitap2_Slot4_Enable = false
|
||||||
|
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
|
||||||
|
|
||||||
|
|
||||||
|
[Folders]
|
||||||
|
Bios = {{{BIOS_PATH}}}
|
||||||
|
Snapshots = {{{SNAPSHOTS_PATH}}}
|
||||||
|
SaveStates = {{{SAVE_STATES_PATH}}}
|
||||||
|
MemoryCards = {{{MEMORY_CARDS_PATH}}}
|
||||||
|
Cache = {{{CACHE_PATH}}}
|
||||||
|
Covers = {{{COVERS_PATH}}}
|
||||||
|
Logs = logs
|
||||||
|
Textures = {{{TEXTURES_PATH}}}
|
||||||
|
Videos = videos
|
||||||
|
|
||||||
|
|
||||||
|
[InputSources]
|
||||||
|
Keyboard = true
|
||||||
|
Mouse = true
|
||||||
|
SDL = true
|
||||||
|
SDLControllerEnhancedMode = false
|
||||||
|
|
||||||
|
|
||||||
|
[Hotkeys]
|
||||||
|
ToggleFullscreen = SDL-0/Start & SDL-0/LeftStick
|
||||||
|
CycleInterlaceMode = Keyboard/F5
|
||||||
|
CycleMipmapMode = Keyboard/Insert
|
||||||
|
GSDumpMultiFrame = Keyboard/Control & Keyboard/Shift & Keyboard/F8
|
||||||
|
Screenshot = Keyboard/F8
|
||||||
|
GSDumpSingleFrame = Keyboard/Shift & Keyboard/F8
|
||||||
|
ZoomIn = Keyboard/Control & Keyboard/Plus
|
||||||
|
ZoomOut = Keyboard/Control & Keyboard/Minus
|
||||||
|
InputRecToggleMode = Keyboard/Shift & Keyboard/R
|
||||||
|
LoadStateFromSlot = SDL-0/Back & SDL-0/LeftShoulder
|
||||||
|
SaveStateToSlot = SDL-0/Back & SDL-0/RightShoulder
|
||||||
|
ShutdownVM = SDL-0/Back & SDL-0/Start
|
||||||
|
ToggleFrameLimit = Keyboard/F4
|
||||||
|
TogglePause = SDL-0/Back & SDL-0/A
|
||||||
|
ToggleSlowMotion = SDL-0/Back & SDL-0/+LeftTrigger
|
||||||
|
ToggleTurbo = SDL-0/Back & SDL-0/+RightTrigger
|
||||||
|
HoldTurbo = Keyboard/Period
|
||||||
|
ResetVM = SDL-0/Back & SDL-0/LeftStick
|
||||||
|
OpenPauseMenu = SDL-0/Back & SDL-0/RightStick
|
||||||
|
IncreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadUp
|
||||||
|
DecreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadDown
|
||||||
|
CycleAspectRatio = SDL-0/Start & SDL-0/DPadRight
|
||||||
|
ToggleSoftwareRendering = SDL-0/Start & SDL-0/DPadLeft
|
||||||
|
ToggleSoftwareRendering = Keyboard/F9
|
||||||
|
NextSaveStateSlot = SDL-0/Start & SDL-0/RightShoulder
|
||||||
|
PreviousSaveStateSlot = SDL-0/Start & SDL-0/LeftShoulder
|
||||||
|
|
||||||
|
[Pad1]
|
||||||
|
Type = DualShock2
|
||||||
|
Deadzone = 0.000000
|
||||||
|
AxisScale = 1.330000
|
||||||
|
LargeMotorScale = 1.000000
|
||||||
|
SmallMotorScale = 1.000000
|
||||||
|
PressureModifier = 0.5
|
||||||
|
Up = SDL-0/DPadUp
|
||||||
|
Right = SDL-0/DPadRight
|
||||||
|
Down = SDL-0/DPadDown
|
||||||
|
Left = SDL-0/DPadLeft
|
||||||
|
Triangle = SDL-0/Y
|
||||||
|
Circle = SDL-0/B
|
||||||
|
Cross = SDL-0/A
|
||||||
|
Square = SDL-0/X
|
||||||
|
Select = SDL-0/Back
|
||||||
|
Start = SDL-0/Start
|
||||||
|
L1 = SDL-0/LeftShoulder
|
||||||
|
L2 = SDL-0/+LeftTrigger
|
||||||
|
R1 = SDL-0/RightShoulder
|
||||||
|
R2 = SDL-0/+RightTrigger
|
||||||
|
L3 = SDL-0/LeftStick
|
||||||
|
R3 = SDL-0/RightStick
|
||||||
|
LUp = SDL-0/-LeftY
|
||||||
|
LRight = SDL-0/+LeftX
|
||||||
|
LDown = SDL-0/+LeftY
|
||||||
|
LLeft = SDL-0/-LeftX
|
||||||
|
RUp = SDL-0/-RightY
|
||||||
|
RRight = SDL-0/+RightX
|
||||||
|
RDown = SDL-0/+RightY
|
||||||
|
RLeft = SDL-0/-RightX
|
||||||
|
Analog = SDL-0/Guide
|
||||||
|
LargeMotor = SDL-0/LargeMotor
|
||||||
|
SmallMotor = SDL-0/SmallMotor
|
||||||
|
Pressure = Keyboard/S
|
||||||
|
|
||||||
|
[Pad2]
|
||||||
|
Type = DualShock2
|
||||||
|
Deadzone = 0.000000
|
||||||
|
AxisScale = 1.330000
|
||||||
|
LargeMotorScale = 1.000000
|
||||||
|
SmallMotorScale = 1.000000
|
||||||
|
PressureModifier = 0.300000
|
||||||
|
Up = SDL-1/DPadUp
|
||||||
|
Right = SDL-1/DPadRight
|
||||||
|
Down = SDL-1/DPadDown
|
||||||
|
Left = SDL-1/DPadLeft
|
||||||
|
Triangle = SDL-1/Y
|
||||||
|
Circle = SDL-1/B
|
||||||
|
Cross = SDL-1/A
|
||||||
|
Square = SDL-1/X
|
||||||
|
Select = SDL-1/Back
|
||||||
|
Start = SDL-1/Start
|
||||||
|
L1 = SDL-1/LeftShoulder
|
||||||
|
L2 = SDL-1/+LeftTrigger
|
||||||
|
R1 = SDL-1/RightShoulder
|
||||||
|
R2 = SDL-1/+RightTrigger
|
||||||
|
L3 = SDL-1/LeftStick
|
||||||
|
R3 = SDL-1/RightStick
|
||||||
|
Analog = SDL-1/Guide
|
||||||
|
LUp = SDL-1/-LeftY
|
||||||
|
LRight = SDL-1/+LeftX
|
||||||
|
LDown = SDL-1/+LeftY
|
||||||
|
LLeft = SDL-1/-LeftX
|
||||||
|
RUp = SDL-1/-RightY
|
||||||
|
RRight = SDL-1/+RightX
|
||||||
|
RDown = SDL-1/+RightY
|
||||||
|
RLeft = SDL-1/-RightX
|
||||||
|
LargeMotor = SDL-1/LargeMotor
|
||||||
|
SmallMotor = SDL-1/SmallMotor
|
||||||
|
|
||||||
|
[GameList]
|
||||||
|
RecursivePaths = {{{RECURSIVE_PATHS}}}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.pcsx2",
|
||||||
|
"displayName": "PCSX2 Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "PCSX2 Emulator Integration",
|
||||||
|
"main": "./pcsx2.ts",
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"ps2",
|
||||||
|
"pcsx2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
|
||||||
|
import { config, db } from "@/bun/api/app";
|
||||||
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
|
import configFile from './PCSX2.ini' with { type: 'file' };
|
||||||
|
import Mustache from 'mustache';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import desc from './package.json';
|
||||||
|
|
||||||
|
export default class PCSX2Integration implements PluginType
|
||||||
|
{
|
||||||
|
load (ctx: PluginContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
|
{
|
||||||
|
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
|
{
|
||||||
|
const args = ["-batch"];
|
||||||
|
if (config.get('launchInFullscreen'))
|
||||||
|
{
|
||||||
|
args.push("-fullscreen");
|
||||||
|
}
|
||||||
|
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]);
|
||||||
|
|
||||||
|
const configFileContents = await Bun.file(configFile).text();
|
||||||
|
|
||||||
|
const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2');
|
||||||
|
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2');
|
||||||
|
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2');
|
||||||
|
|
||||||
|
const view = {
|
||||||
|
BIOS_PATH: biosFolder,
|
||||||
|
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
|
||||||
|
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
|
||||||
|
MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'),
|
||||||
|
CACHE_PATH: path.join(storageFolder, 'cache'),
|
||||||
|
COVERS_PATH: path.join(storageFolder, 'covers'),
|
||||||
|
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
||||||
|
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Object.values(view).map(p => ensureDir(p)));
|
||||||
|
|
||||||
|
await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis', 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadBios (id: number)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/bun/api/plugins/plugin-manager.ts
Normal file
94
src/bun/api/plugins/plugin-manager.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { GameflowHooks } from "../hooks/app";
|
||||||
|
import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema";
|
||||||
|
import { config } from "../app";
|
||||||
|
|
||||||
|
export class PluginManager
|
||||||
|
{
|
||||||
|
hooks = new GameflowHooks();
|
||||||
|
plugins: Record<string, {
|
||||||
|
enabled: boolean,
|
||||||
|
loaded: boolean,
|
||||||
|
plugin: PluginType;
|
||||||
|
description: PluginDescriptionType,
|
||||||
|
source: PluginSourceType;
|
||||||
|
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (this.plugins[description.name])
|
||||||
|
{
|
||||||
|
console.error("Plugin with name", description.name, "already registered");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (plugin.setup) await plugin.setup();
|
||||||
|
this.plugins[description.name] = {
|
||||||
|
enabled: !config.get('disabledPlugins').includes(description.name),
|
||||||
|
loaded: false, plugin: plugin,
|
||||||
|
source: source,
|
||||||
|
description: description
|
||||||
|
};
|
||||||
|
this.reload(description.name);
|
||||||
|
console.log("Plugin", description.name, "registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.log("Error While Registering plugin");
|
||||||
|
console.error(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload (name: string)
|
||||||
|
{
|
||||||
|
const plugin = this.plugins[name];
|
||||||
|
if (plugin)
|
||||||
|
{
|
||||||
|
const ctx: PluginContextType = { hooks: this.hooks };
|
||||||
|
|
||||||
|
if (plugin.loaded)
|
||||||
|
{
|
||||||
|
plugin.plugin.onBeforeReload?.(ctx);
|
||||||
|
plugin.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (plugin.enabled)
|
||||||
|
{
|
||||||
|
plugin.plugin.load(ctx);
|
||||||
|
plugin.loaded = true;
|
||||||
|
}
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.log("Error for plugin", plugin.description.name, "while loading");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadAll ()
|
||||||
|
{
|
||||||
|
this.hooks = new GameflowHooks();
|
||||||
|
Object.keys(this.plugins).forEach(id => this.reload(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup ()
|
||||||
|
{
|
||||||
|
await Promise.all(Object.values(this.plugins).filter(p => p.loaded && p.plugin.cleanup).map(async p =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await p.plugin.cleanup!();
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.log("Error for plugin", p.description.name, "while cleaning up");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/bun/api/plugins/plugins.ts
Normal file
37
src/bun/api/plugins/plugins.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Elysia, { status } from "elysia";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
import z from "zod";
|
||||||
|
import { toggleElementInConfig } from "@/bun/utils";
|
||||||
|
|
||||||
|
export default new Elysia({ prefix: '/plugins' })
|
||||||
|
.get('/', async () =>
|
||||||
|
{
|
||||||
|
return Object.values(plugins.plugins).map(p =>
|
||||||
|
{
|
||||||
|
const plugin: FrontendPlugin = {
|
||||||
|
enabled: p.enabled,
|
||||||
|
name: p.description.name,
|
||||||
|
displayName: p.description.displayName,
|
||||||
|
description: p.description.description,
|
||||||
|
source: p.source,
|
||||||
|
version: p.description.version,
|
||||||
|
icon: p.description.icon
|
||||||
|
};
|
||||||
|
return plugin;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
||||||
|
{
|
||||||
|
const plugin = plugins.plugins[id];
|
||||||
|
if (plugin)
|
||||||
|
{
|
||||||
|
plugin.enabled = enabled;
|
||||||
|
toggleElementInConfig('disabledPlugins', plugin.description.name, enabled);
|
||||||
|
plugins.reloadAll();
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return status("Not Found");
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: z.object({ enabled: z.boolean() })
|
||||||
|
});
|
||||||
25
src/bun/api/plugins/register-plugins.ts
Normal file
25
src/bun/api/plugins/register-plugins.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { PluginManager } from "./plugin-manager";
|
||||||
|
|
||||||
|
import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json';
|
||||||
|
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default async function register (pluginManager: PluginManager)
|
||||||
|
{
|
||||||
|
|
||||||
|
const plugins: (PluginDescriptionType & { main: string; root: string; })[] = [
|
||||||
|
{ ...pcsx2, root: './builtin/emulators/com.simeonradivoev.gameflow.pcsx2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(plugins.map(async (pluginPackage) =>
|
||||||
|
{
|
||||||
|
const file = await import(`./${path.join(pluginPackage.root, pluginPackage.main)}`);
|
||||||
|
if (file.default && typeof file.default === 'function')
|
||||||
|
{
|
||||||
|
const pluginInstance = new file.default();
|
||||||
|
const plugin = await PluginSchema.parseAsync(pluginInstance);
|
||||||
|
const description = await PluginDescriptionSchema.parseAsync(pluginPackage);
|
||||||
|
pluginManager.register(plugin, description, 'builtin');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,17 @@ import { system } from "./system";
|
||||||
import { store } from "./store/store";
|
import { store } from "./store/store";
|
||||||
import { host } from "../utils/host";
|
import { host } from "../utils/host";
|
||||||
import { jobs } from "./jobs/jobs";
|
import { jobs } from "./jobs/jobs";
|
||||||
|
import plugins from "./plugins/plugins";
|
||||||
|
|
||||||
const api = new Elysia({ serve: {} })
|
const api = new Elysia({ serve: {} })
|
||||||
.use([cors(), clients, settings, system, store, jobs]);
|
.use([cors(), clients, settings, system, store, jobs, plugins]);
|
||||||
|
|
||||||
export type RommAPIType = typeof clients;
|
export type RommAPIType = typeof clients;
|
||||||
export type SettingsAPIType = typeof settings;
|
export type SettingsAPIType = typeof settings;
|
||||||
export type SystemAPIType = typeof system;
|
export type SystemAPIType = typeof system;
|
||||||
export type StoreAPIType = typeof store;
|
export type StoreAPIType = typeof store;
|
||||||
export type JobsAPIType = typeof jobs;
|
export type JobsAPIType = typeof jobs;
|
||||||
|
export type PluginsAPIType = typeof plugins;
|
||||||
|
|
||||||
export function RunAPIServer ()
|
export function RunAPIServer ()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { customEmulators, db, emulatorsDb } from '../app';
|
import { db, emulatorsDb } from '../app';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import { cores } from '../emulatorjs/emulatorjs';
|
import { cores } from '../emulatorjs/emulatorjs';
|
||||||
import { FrontEndEmulator, SERVER_URL } from '@/shared/constants';
|
import { SERVER_URL } from '@/shared/constants';
|
||||||
import { findExecsByName } from '../games/services/launchGameService';
|
import { findExecsByName } from '../games/services/launchGameService';
|
||||||
import { host } from '@/bun/utils/host';
|
import { host } from '@/bun/utils/host';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants";
|
import { EmulatorPackageType } from "@/shared/constants";
|
||||||
import { emulatorsDb } from "../../app";
|
import { emulatorsDb, plugins } from "../../app";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { findExecs } from "../../games/services/launchGameService";
|
import { findExecs } from "../../games/services/launchGameService";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -10,7 +10,7 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
||||||
icon: string;
|
icon: string;
|
||||||
}[])
|
}[])
|
||||||
{
|
{
|
||||||
let execPath: EmulatorSourceType | undefined;
|
let execPath: EmulatorSourceEntryType | undefined;
|
||||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||||
|
|
||||||
if (esEmulator)
|
if (esEmulator)
|
||||||
|
|
@ -24,8 +24,17 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
||||||
logo: emulator.logo,
|
logo: emulator.logo,
|
||||||
systems,
|
systems,
|
||||||
gameCount,
|
gameCount,
|
||||||
validSource: execPath
|
validSource: execPath,
|
||||||
|
integration: findEmulatorPluginIntegration(emulator.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
return em;
|
return em;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmulatorPluginIntegration (name: string)
|
||||||
|
{
|
||||||
|
const lowerCaseName = name.toLowerCase();
|
||||||
|
const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName));
|
||||||
|
if (!integration) return undefined;
|
||||||
|
return { name: integration[0], version: integration[1].description.version };
|
||||||
}
|
}
|
||||||
|
|
@ -3,17 +3,18 @@ import Elysia, { status } from "elysia";
|
||||||
import { config, db, taskQueue } from "../app";
|
import { config, db, taskQueue } from "../app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
|
import { StoreGameSchema } from "@/shared/constants";
|
||||||
import { findExecsByName } from "../games/services/launchGameService";
|
import { findExecsByName } from "../games/services/launchGameService";
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||||
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
||||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService";
|
||||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||||
import { Glob } from "bun";
|
import { Glob } from "bun";
|
||||||
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
|
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService";
|
||||||
|
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
||||||
|
|
||||||
export const store = new Elysia({ prefix: '/api/store' })
|
export const store = new Elysia({ prefix: '/api/store' })
|
||||||
.get('/emulators', async ({ query }) =>
|
.get('/emulators', async ({ query }) =>
|
||||||
|
|
@ -97,13 +98,11 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
})
|
})
|
||||||
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
||||||
{
|
{
|
||||||
const downlodDir = config.get('downloadPath');
|
return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name));
|
||||||
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
|
||||||
},
|
},
|
||||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const downlodDir = config.get('downloadPath');
|
|
||||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||||
if (!emulatorPackage) return status("Not Found");
|
if (!emulatorPackage) return status("Not Found");
|
||||||
|
|
||||||
|
|
@ -111,9 +110,12 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
|
|
||||||
const execPaths = await findExecsByName(emulatorPackage.name);
|
const execPaths = await findExecsByName(emulatorPackage.name);
|
||||||
|
|
||||||
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
|
const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id);
|
||||||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||||
const validExec = execPaths.find(p => p.exists);
|
const validExec = execPaths.find(p => p.exists);
|
||||||
|
const biosDirPath = path.join(config.get('downloadPath'), 'bios', id);
|
||||||
|
const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : [];
|
||||||
|
|
||||||
const emulator: FrontEndEmulatorDetailed = {
|
const emulator: FrontEndEmulatorDetailed = {
|
||||||
name: emulatorPackage.name,
|
name: emulatorPackage.name,
|
||||||
description: emulatorPackage.description,
|
description: emulatorPackage.description,
|
||||||
|
|
@ -138,7 +140,10 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
return { name: d.type, type: "Unknown" };
|
return { name: d.type, type: "Unknown" };
|
||||||
}) ?? []),
|
}) ?? []),
|
||||||
logo: emulatorPackage.logo,
|
logo: emulatorPackage.logo,
|
||||||
sources: execPaths
|
sources: execPaths,
|
||||||
|
biosRequirement: emulatorPackage.bios,
|
||||||
|
bios: biosFiles,
|
||||||
|
integration: findEmulatorPluginIntegration(emulatorPackage.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
return emulator;
|
return emulator;
|
||||||
|
|
@ -154,7 +159,6 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
})
|
})
|
||||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
|
|
||||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||||
if (await fs.exists(storeEmulatorFolder))
|
if (await fs.exists(storeEmulatorFolder))
|
||||||
{
|
{
|
||||||
|
|
@ -162,4 +166,24 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
return status("OK");
|
return status("OK");
|
||||||
}
|
}
|
||||||
return status("Not Found");
|
return status("Not Found");
|
||||||
|
})
|
||||||
|
.post('/download/bios/:id', async ({ params: { id } }) =>
|
||||||
|
{
|
||||||
|
if (taskQueue.findJob(BiosDownloadJob.query({ id }), BiosDownloadJob))
|
||||||
|
{
|
||||||
|
return status("Conflict", "Bios Download Already Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskQueue.enqueue(BiosDownloadJob.query({ id }), new BiosDownloadJob(id));
|
||||||
|
})
|
||||||
|
.delete('/bios/:id', async ({ params: { id } }) =>
|
||||||
|
{
|
||||||
|
const biosFolder = path.join(config.get('downloadPath'), "bios", id);
|
||||||
|
if (await fs.exists(biosFolder))
|
||||||
|
{
|
||||||
|
await fs.rm(biosFolder, { recursive: true });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return status("Not Found");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -7,7 +7,7 @@ import { isSteamDeck, openExternal } from "../utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import buildNotificationsStream from "./notifications";
|
import buildNotificationsStream from "./notifications";
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
import { DirSchema } from "@/shared/constants";
|
||||||
import { getDevices, getDevicesCurated } from "./drives";
|
import { getDevices, getDevicesCurated } from "./drives";
|
||||||
import getFolderSize from "get-folder-size";
|
import getFolderSize from "get-folder-size";
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
|
|
||||||
import { JobStatus } from '@/shared/constants';
|
|
||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
import z, { ZodTypeAny } from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
export class TaskQueue
|
export class TaskQueue
|
||||||
{
|
{
|
||||||
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
private activeQueue: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||||
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
private queue?: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||||
|
|
||||||
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
||||||
|
|
@ -36,6 +36,8 @@ export class TaskQueue
|
||||||
{
|
{
|
||||||
const index = this.activeQueue.indexOf(job.job);
|
const index = this.activeQueue.indexOf(job.job);
|
||||||
this.activeQueue.splice(index, 1);
|
this.activeQueue.splice(index, 1);
|
||||||
|
// We need to call it after it has been removed from the queue, so that the has active of type doesn't return true
|
||||||
|
this.events?.emit('ended', { id: job.job.context.id, job: job.job.context });
|
||||||
setTimeout(() => this.processQueue(), 0);
|
setTimeout(() => this.processQueue(), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -162,7 +164,7 @@ type JobClassWithStatics = JobClass & {
|
||||||
export type JobContextFromClass<C extends JobClassWithStatics> =
|
export type JobContextFromClass<C extends JobClassWithStatics> =
|
||||||
JobContext<
|
JobContext<
|
||||||
InstanceType<C>,
|
InstanceType<C>,
|
||||||
C extends { dataSchema: ZodTypeAny; }
|
C extends { dataSchema: z.ZodAny; }
|
||||||
? z.infer<C['dataSchema']>
|
? z.infer<C['dataSchema']>
|
||||||
: never,
|
: never,
|
||||||
C['id']
|
C['id']
|
||||||
|
|
@ -215,7 +217,6 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
||||||
} finally
|
} finally
|
||||||
{
|
{
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.events.emit('ended', { id: this.m_id, job: this });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
src/bun/types/types.d.ts
vendored
21
src/bun/types/types.d.ts
vendored
|
|
@ -1,15 +1,4 @@
|
||||||
import { ChildProcess } from "node:child_process";
|
declare interface ObjectConstructor
|
||||||
|
|
||||||
declare const IS_BINARY: string;
|
|
||||||
|
|
||||||
export type ActiveGame = {
|
|
||||||
process?: ChildProcess;
|
|
||||||
gameId: number;
|
|
||||||
name: string;
|
|
||||||
command: { command: string, startDir?: string; };
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ObjectConstructor
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Groups members of an iterable according to the return value of the passed callback.
|
* Groups members of an iterable according to the return value of the passed callback.
|
||||||
|
|
@ -22,7 +11,7 @@ interface ObjectConstructor
|
||||||
): Partial<Record<K, T[]>>;
|
): Partial<Record<K, T[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapConstructor
|
declare interface MapConstructor
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Groups members of an iterable according to the return value of the passed callback.
|
* Groups members of an iterable according to the return value of the passed callback.
|
||||||
|
|
@ -33,4 +22,10 @@ interface MapConstructor
|
||||||
items: Iterable<T>,
|
items: Iterable<T>,
|
||||||
keySelector: (item: T, index: number) => K,
|
keySelector: (item: T, index: number) => K,
|
||||||
): Map<K, T[]>;
|
): Map<K, T[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface AppEventMap
|
||||||
|
{
|
||||||
|
exitapp: [];
|
||||||
|
notification: [FrontendNotification];
|
||||||
}
|
}
|
||||||
35
src/bun/types/typesc.schema.ts
Normal file
35
src/bun/types/typesc.schema.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { GameflowHooks } from "../api/hooks/app";
|
||||||
|
import { ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
|
export const PluginContextSchema = z.object({
|
||||||
|
hooks: z.instanceof(GameflowHooks)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PluginDescriptionSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
displayName: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
icon: z.url().optional(),
|
||||||
|
keywords: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PluginSchema = z.object({
|
||||||
|
setup: z.function().output(z.promise(z.void())).optional(),
|
||||||
|
load: z.function().input([PluginContextSchema]).output(z.void()),
|
||||||
|
onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(),
|
||||||
|
cleanup: z.function().output(z.promise(z.void())).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginType = z.infer<typeof PluginSchema>;
|
||||||
|
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
||||||
|
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
||||||
|
|
||||||
|
export const ActiveGameSchema = z.object({
|
||||||
|
process: z.instanceof(ChildProcess).optional(),
|
||||||
|
gameId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
command: z.object({ command: z.string(), startDir: z.string().optional() })
|
||||||
|
});
|
||||||
|
export type ActiveGameType = z.infer<typeof ActiveGameSchema>;
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { $ } from 'bun';
|
import { $, sleep } from 'bun';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
|
import { SettingsType } from '@/shared/constants';
|
||||||
|
import { config } from './api/app';
|
||||||
|
|
||||||
export function checkRunning (pid: number)
|
export function checkRunning (pid: number)
|
||||||
{
|
{
|
||||||
|
|
@ -111,3 +113,33 @@ export function shuffleInPlace (array: any[], startSeed?: number)
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleElementInConfig<T> (id: KeysWithValueAssignableTo<SettingsType, Array<T>>, element: T, enabled: boolean)
|
||||||
|
{
|
||||||
|
const disabled = config.get(id as any) as T[];
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
const index = disabled.indexOf(element);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
config.set('disabledPlugins', disabled.concat(element));
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const index = disabled.indexOf(element);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
config.set('disabledPlugins', disabled.toSpliced(index, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function simulateProgress (setProgress: (p: number) => void, signal?: AbortSignal)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
setProgress(i * 10);
|
||||||
|
if (signal && signal.aborted) return;
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import fs from 'node:fs/promises';
|
||||||
|
|
||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { config, jar } from "../api/app";
|
import { config, jar } from "../api/app";
|
||||||
import { file } from "bun";
|
|
||||||
|
|
||||||
export interface FileEntry
|
export interface FileEntry
|
||||||
{
|
{
|
||||||
|
|
@ -24,6 +23,10 @@ interface TmpDownloadMetadata
|
||||||
files: FileEntry[];
|
files: FileEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It download files and reports progress.
|
||||||
|
* It also automatically applies cookies from the jar store.
|
||||||
|
*/
|
||||||
export class Downloader
|
export class Downloader
|
||||||
{
|
{
|
||||||
files: FileEntry[];
|
files: FileEntry[];
|
||||||
|
|
|
||||||
|
|
@ -119,16 +119,18 @@ export function AnimatedBackground (data: {
|
||||||
|
|
||||||
>
|
>
|
||||||
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
|
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
|
||||||
<div className='fixed bg-base-100 top-0 left-0 right-0 bottom-0 -z-5'></div>
|
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4 mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner' src={finalLastBackgroundUrl.href}></img>}
|
||||||
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4' src={finalLastBackgroundUrl.href}></img>}
|
|
||||||
{finalBackgroundUrl ? <img
|
{finalBackgroundUrl ? <img
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
key={finalBackgroundUrl?.href}
|
key={finalBackgroundUrl?.href}
|
||||||
className={'absolute w-full h-full object-cover object-center opacity-0 -z-3'}
|
className={'absolute w-full h-full object-cover object-center opacity-0 -z-3 mask-radial-from-0'}
|
||||||
src={finalBackgroundUrl?.href}
|
src={finalBackgroundUrl?.href}
|
||||||
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
|
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
|
||||||
></img> : <><div className='mobile:hidden bg-gradient'></div></>}
|
></img> : <><div className='mobile:hidden bg-gradient'></div></>}
|
||||||
<div className='absolute top-0 left-0 right-0 bottom-0 bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
|
<div className='absolute top-0 left-0 right-0 bottom-0 bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
|
||||||
<div className='mobile:hidden bg-noise'></div>
|
<div className='mobile:hidden bg-noise'></div>
|
||||||
|
<div className='mobile:hidden bg-dots'></div>
|
||||||
</div>}
|
</div>}
|
||||||
{data.animated && animateBackground && <div className="fixed overflow-hidden top-0 left-0 right-0 bottom-0" style={{ zIndex: -1 }}>
|
{data.animated && animateBackground && <div className="fixed overflow-hidden top-0 left-0 right-0 bottom-0" style={{ zIndex: -1 }}>
|
||||||
{backgroundElements}
|
{backgroundElements}
|
||||||
|
|
@ -147,6 +149,7 @@ export function AnimatedBackground (data: {
|
||||||
backgroundColor: "var(--color-base-300)",
|
backgroundColor: "var(--color-base-300)",
|
||||||
} : {}}></div>
|
} : {}}></div>
|
||||||
<div className='mobile:hidden bg-noise opacity-30 z-1'></div>
|
<div className='mobile:hidden bg-noise opacity-30 z-1'></div>
|
||||||
|
<div className='mobile:hidden bg-dots opacity-30 z-1'></div>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</AnimatedBackgroundContext >
|
</AnimatedBackgroundContext >
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface GameCardParams
|
||||||
type?: string;
|
type?: string;
|
||||||
subtitle: string | JSX.Element;
|
subtitle: string | JSX.Element;
|
||||||
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
||||||
|
srcset?: string;
|
||||||
focusKey: string;
|
focusKey: string;
|
||||||
index: number;
|
index: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -64,7 +65,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
data.onAction?.();
|
data.onAction?.();
|
||||||
}}
|
}}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative game-card bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-xl focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none",
|
"relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none",
|
||||||
data.className
|
data.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -75,7 +76,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
classNames({ "h-full": typeof data.preview === "string" })
|
classNames({ "h-full": typeof data.preview === "string" })
|
||||||
)}>
|
)}>
|
||||||
{typeof data.preview === "string" ? (
|
{typeof data.preview === "string" ? (
|
||||||
<img draggable={false} className={classNames("object-cover w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
<img draggable={false} srcSet={data.srcset} className={classNames("object-cover w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} loading="lazy" decoding="async" ></img>
|
||||||
) : (
|
) : (
|
||||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export function CardList (data: {
|
||||||
data-index={i}
|
data-index={i}
|
||||||
title={g.title}
|
title={g.title}
|
||||||
subtitle={g.subtitle ?? ""}
|
subtitle={g.subtitle ?? ""}
|
||||||
|
srcset={g.previewSrcset}
|
||||||
onFocus={(id, node, details) =>
|
onFocus={(id, node, details) =>
|
||||||
{
|
{
|
||||||
g.onFocus?.(details);
|
g.onFocus?.(details);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default function CollectionList (data: {
|
||||||
title: g.name,
|
title: g.name,
|
||||||
focusKey: `collection-${g.id}`,
|
focusKey: `collection-${g.id}`,
|
||||||
subtitle: g.owner_username,
|
subtitle: g.owner_username,
|
||||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_small[0]}`,
|
||||||
badges: [
|
badges: [
|
||||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||||
{g.rom_count}
|
{g.rom_count}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ import { JSX, Suspense, useEffect } from 'react';
|
||||||
import Shortcuts from './Shortcuts';
|
import Shortcuts from './Shortcuts';
|
||||||
import { AutoFocus } from './AutoFocus';
|
import { AutoFocus } from './AutoFocus';
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
|
||||||
import { GameListFilterType } from '@/shared/constants';
|
import { GameListFilterType } from '@/shared/constants';
|
||||||
import { GameCardFocusHandler } from './CardElement';
|
import { GameCardFocusHandler } from './CardElement';
|
||||||
import { Router } from '..';
|
|
||||||
import { HandleGoBack } from '../scripts/utils';
|
import { HandleGoBack } from '../scripts/utils';
|
||||||
|
|
||||||
export interface CollectionsDetailParams
|
export interface CollectionsDetailParams
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||||
import { ContextDialogContext } from "../scripts/contexts";
|
import { ContextDialogContext } from "../scripts/contexts";
|
||||||
|
import { FOCUS_KEYS } from "../scripts/types";
|
||||||
|
|
||||||
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||||
{
|
{
|
||||||
|
|
@ -25,18 +26,18 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
};
|
};
|
||||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||||
const { ref, focusSelf, focusKey } = useFocusable({
|
const { ref, focusSelf, focusKey } = useFocusable({
|
||||||
focusKey: `${context.id}-list-option-${data.id}`,
|
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
|
||||||
onEnterPress: data.shortcuts ? undefined : handleAction,
|
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||||
onFocus: handleFocus,
|
onFocus: handleFocus,
|
||||||
trackChildren: typeof data.content !== 'string'
|
trackChildren: typeof data.content !== 'string'
|
||||||
});
|
});
|
||||||
const colors = {
|
const colors = {
|
||||||
primary: "active:bg-primary control-pointer:hover:bg-primary focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content",
|
primary: "active:bg-primary control-pointer:hover:bg-primary control-pointer:hover:text-primary-content focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content",
|
||||||
secondary: "active:bg-secondary control-pointer:hover:bg-secondary focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content",
|
secondary: "active:bg-secondary control-pointer:hover:bg-secondary control-pointer:hover:text-secondary-content focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content",
|
||||||
accent: "active:bg-accent control-pointer:hover:bg-accent focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content",
|
accent: "active:bg-accent control-pointer:hover:bg-accent control-pointer:hover:text-accent-content focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content",
|
||||||
info: "active:bg-info control-pointer:hover:bg-info focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content",
|
info: "active:bg-info control-pointer:hover:bg-info control-pointer:hover:text-info-content focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content",
|
||||||
warning: "active:bg-warning control-pointer:hover:bg-warning focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content",
|
warning: "active:bg-warning control-pointer:hover:bg-warning control-pointer:hover:text-warning-content focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content",
|
||||||
error: "active:bg-error control-pointer:hover:bg-error focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content"
|
error: "active:bg-error control-pointer:hover:bg-error control-pointer:hover:text-error-content focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content"
|
||||||
};
|
};
|
||||||
if (data.shortcuts)
|
if (data.shortcuts)
|
||||||
{
|
{
|
||||||
|
|
@ -47,9 +48,10 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
className={
|
className={
|
||||||
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
|
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2 active:animate-scale in-focused:font-semibold",
|
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2 active:animate-scale in-focused:font-semibold",
|
||||||
data.className,
|
data.className,
|
||||||
colors[data.type])}>
|
colors[data.type],
|
||||||
|
"active:bg-base-content! active:text-base-300! active:transition-none")}>
|
||||||
{data.icon}
|
{data.icon}
|
||||||
{data.content}
|
{data.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,33 +73,34 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
|
||||||
{
|
{
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
|
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
|
||||||
const dialog = <ContextDialog id={id} open={open} close={() =>
|
const handleClose = (value: boolean, newSourceFocusKey?: string) =>
|
||||||
{
|
{
|
||||||
setOpen(false);
|
if (value === open) return;
|
||||||
data.onClose?.();
|
if (value)
|
||||||
}} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}>
|
{
|
||||||
|
setOpen(true);
|
||||||
|
setSourceFocusKey(newSourceFocusKey);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
setOpen(false);
|
||||||
|
data.onClose?.();
|
||||||
|
if (newSourceFocusKey)
|
||||||
|
{
|
||||||
|
setFocus(newSourceFocusKey);
|
||||||
|
} else if (sourceFocusKey)
|
||||||
|
{
|
||||||
|
setFocus(sourceFocusKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
const dialog = <ContextDialog id={id} open={open} close={handleClose} className={data.className} preferredChildFocusKey={data.preferredChildFocusKey}>
|
||||||
{data.content}
|
{data.content}
|
||||||
</ContextDialog>;
|
</ContextDialog>;
|
||||||
return {
|
return {
|
||||||
dialog,
|
dialog,
|
||||||
open,
|
open,
|
||||||
setOpen: (value: boolean, sourceFocusKey?: string) =>
|
setOpen: handleClose
|
||||||
{
|
|
||||||
if (value === open) return;
|
|
||||||
if (value)
|
|
||||||
{
|
|
||||||
setOpen(true);
|
|
||||||
setSourceFocusKey(sourceFocusKey);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
setOpen(false);
|
|
||||||
if (sourceFocusKey)
|
|
||||||
{
|
|
||||||
setFocus(sourceFocusKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +111,6 @@ export function ContextDialog (data: {
|
||||||
close: (open: boolean) => void;
|
close: (open: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
preferredChildFocusKey?: string;
|
preferredChildFocusKey?: string;
|
||||||
sourceFocusKey?: string;
|
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
|
|
@ -137,7 +139,7 @@ export function ContextDialog (data: {
|
||||||
}] : [], [data.open]);
|
}] : [], [data.open]);
|
||||||
|
|
||||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||||
classNames({ "opacity-0": !data.open }))
|
classNames({ "opacity-0": !data.open }))
|
||||||
}
|
}
|
||||||
onClick={handleClose}>
|
onClick={handleClose}>
|
||||||
|
|
@ -145,7 +147,7 @@ export function ContextDialog (data: {
|
||||||
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
|
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto backdrop-blur-2xl",
|
||||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||||
data.className)
|
data.className)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export default function Error (data: ErrorComponentProps)
|
||||||
<Button className="text-2xl! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
<Button className="text-2xl! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||||
<div className="mobile:hidden bg-gradient"></div>
|
<div className="mobile:hidden bg-gradient"></div>
|
||||||
<div className="mobile:hidden bg-noise"></div>
|
<div className="mobile:hidden bg-noise"></div>
|
||||||
|
<div className="mobile:hidden bg-dots"></div>
|
||||||
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { ContextList, DialogEntry } from "./ContextDialog";
|
import { ContextList, DialogEntry } from "./ContextDialog";
|
||||||
import { systemApi } from "../scripts/clientApi";
|
import { systemApi } from "../scripts/clientApi";
|
||||||
import { useContext, useRef, useState } from "react";
|
import { useContext, useRef, useState } from "react";
|
||||||
import path, { dirname } from "pathe";
|
import path from "pathe";
|
||||||
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { DirType } from "@/shared/constants";
|
import { DirType } from "@/shared/constants";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useGlobalFocus } from "../scripts/spatialNavigation";
|
import { useGlobalFocus } from "../scripts/spatialNavigation";
|
||||||
import { JSX, RefObject, useMemo, useState } from "react";
|
import { RefObject, useMemo, useState } from "react";
|
||||||
import { useEventListener } from "usehooks-ts";
|
import { useEventListener } from "usehooks-ts";
|
||||||
|
|
||||||
function ScrollDot (data: { index: number; parent: RefObject<HTMLElement | null>, peers: HTMLElement[]; })
|
function ScrollDot (data: { index: number; parent: RefObject<HTMLElement | null>, peers: HTMLElement[]; })
|
||||||
|
|
|
||||||
36
src/mainview/components/FocusTooltip.tsx
Normal file
36
src/mainview/components/FocusTooltip.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Ref, RefObject, useEffect, useState } from "react";
|
||||||
|
import { useFocusEventListener } from "../scripts/spatialNavigation";
|
||||||
|
import useActiveControl from "../scripts/gamepads";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export default function FocusTooltip (data: { parentRef: RefObject<any>; visible?: boolean; })
|
||||||
|
{
|
||||||
|
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||||
|
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
||||||
|
|
||||||
|
const handleTooltipSet = (e: HTMLElement) =>
|
||||||
|
{
|
||||||
|
const dataTooltip = e.getAttribute('data-tooltip');
|
||||||
|
setHoverText(dataTooltip ?? undefined);
|
||||||
|
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent');
|
||||||
|
};
|
||||||
|
|
||||||
|
const { isPointer } = useActiveControl();
|
||||||
|
|
||||||
|
useFocusEventListener('focuschanged', (e) =>
|
||||||
|
{
|
||||||
|
if (e.target instanceof HTMLElement)
|
||||||
|
{
|
||||||
|
handleTooltipSet(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, data.parentRef);
|
||||||
|
|
||||||
|
const tooltipStyles = {
|
||||||
|
base: 'bg-base-100 text-base-content',
|
||||||
|
accent: 'bg-accent text-accent-content',
|
||||||
|
error: 'bg-error text-error-content'
|
||||||
|
};
|
||||||
|
|
||||||
|
return !!hoverText && (data.visible ?? true) && !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>;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import CardElement from "./CardElement";
|
import CardElement from "./CardElement";
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||||
|
|
@ -57,7 +57,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
||||||
className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""}
|
className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""}
|
||||||
previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""}
|
previewClassName={data.game.id.source === 'local' ? "dark:not-in-focused:opacity-40 light:not-in-focus:opacity-60" : ""}
|
||||||
index={data.index}
|
index={data.index}
|
||||||
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
||||||
/>;
|
/>;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { GameMetaExtra, CardList } from "./CardList";
|
import { GameMetaExtra, CardList } from "./CardList";
|
||||||
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
import { GameListFilterType, RPC_URL } from "@shared/constants";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
import { JSX, useContext } from "react";
|
import { JSX, useContext } from "react";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
import { GameCardFocusHandler } from "./CardElement";
|
||||||
import { useLocalSetting } from "../scripts/utils";
|
import { useLocalSetting } from "../scripts/utils";
|
||||||
|
|
@ -75,7 +75,7 @@ export function GameList (data: GameListParams)
|
||||||
|
|
||||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||||
previewUrl.searchParams.delete('ts');
|
previewUrl.searchParams.delete('ts');
|
||||||
previewUrl.searchParams.set('width', "16");
|
|
||||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||||
platformUrl.searchParams.set('width', "64");
|
platformUrl.searchParams.set('width', "64");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import
|
||||||
Bell,
|
Bell,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
Settings,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiHigh,
|
WifiHigh,
|
||||||
WifiLow,
|
WifiLow,
|
||||||
|
|
@ -22,70 +22,44 @@ import
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { RPC_URL } from "../../shared/constants";
|
||||||
import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react";
|
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { systemApi } from "../scripts/clientApi";
|
import { systemApi } from "../scripts/clientApi";
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
import { useStickyDataAttr } from "../scripts/utils";
|
import { useStickyDataAttr } from "../scripts/utils";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { TwitchIcon } from "../scripts/brandIcons";
|
||||||
|
import { rommUserQuery } from "../scripts/queries/romm";
|
||||||
|
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
|
||||||
|
|
||||||
function HeaderAvatar (data: {
|
function HeaderAvatar (data: {
|
||||||
id: string;
|
id: string;
|
||||||
imageSrc?: string | string[];
|
preview?: string | JSX.Element;
|
||||||
className?: string;
|
className?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
status?: HeaderAccount['status'];
|
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
type?: HeaderAccount['type'];
|
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
|
|
||||||
const bgColors = {
|
|
||||||
primary: " text-primary-content",
|
|
||||||
secondary: " text-secondary-content",
|
|
||||||
accent: " text-accent-content",
|
|
||||||
base: "bg-base-100",
|
|
||||||
none: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={data.id}
|
id={data.id}
|
||||||
ref={ref}
|
|
||||||
onClick={data.onSelect}
|
onClick={data.onSelect}
|
||||||
style={{ viewTransitionName: `header-account-${data.id}` }}
|
style={{ viewTransitionName: `header-account-${data.id}` }}
|
||||||
className={classNames(
|
className={twMerge(
|
||||||
`avatar indicator ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
|
`avatar overflow-visible bg-base-100 indicator border-7 sm:size-8 md:size-14 rounded-full flex items-center justify-center drop-shadow-md`,
|
||||||
bgColors[data.type ?? "none"],
|
|
||||||
"text-base-content cursor-pointer transition-all drop-shadow-md",
|
|
||||||
"hover:ring-primary hover:ring-7 focusable focusable-primary focused:ring-offset-base-100",
|
|
||||||
{
|
|
||||||
"ring-5 hover:ring-offset-5": data.active,
|
|
||||||
"ring-offset-5": focused && data.active,
|
|
||||||
},
|
|
||||||
data.className,
|
data.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.imageSrc ? (
|
{typeof data.preview === 'string' ? (
|
||||||
<div className="overflow rounded-full w-full h-full">
|
<div className="overflow rounded-full w-full h-full">
|
||||||
<picture>
|
<picture>
|
||||||
{typeof data.imageSrc === 'string' && <img key={"og-image"} src={data.imageSrc}></img>}
|
<img key={"og-image"} src={data.preview}></img>
|
||||||
{Array.isArray(data.imageSrc) && data.imageSrc.map((s, i) =>
|
|
||||||
{
|
|
||||||
if (i === (data.imageSrc!.length - 1))
|
|
||||||
{
|
|
||||||
return <img key={'fallback-image'} src={s}></img>;
|
|
||||||
}
|
|
||||||
return <source key={`alt-img-${i}`} srcSet={s}></source>;
|
|
||||||
})}
|
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : data.preview}
|
||||||
<User />
|
|
||||||
)}
|
|
||||||
<span className={classNames("indicator-item status md:left-1 top-1 sm:ring-2 md:ring-3 ring-base-100 z-1", data.status)}></span>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +75,7 @@ export interface HeaderButton
|
||||||
export interface HeaderAccount
|
export interface HeaderAccount
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
previewUrl?: string | string[];
|
preview?: string | JSX.Element;
|
||||||
status?: "status-error" | "status-success" | "status-neutral";
|
status?: "status-error" | "status-success" | "status-neutral";
|
||||||
type?: "base" | "primary" | "secondary" | "accent";
|
type?: "base" | "primary" | "secondary" | "accent";
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
|
@ -228,32 +202,52 @@ function BatteryStatus ()
|
||||||
|
|
||||||
export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
{
|
{
|
||||||
const user = useQuery({
|
const rommUser = useQuery({
|
||||||
...getCurrentUserApiUsersMeGetOptions(),
|
...rommUserQuery(),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1
|
retry: 1
|
||||||
});
|
});
|
||||||
|
const twitchStatus = useQuery({
|
||||||
|
...twitchLoginVerificationQuery, refetchOnWindowFocus: false,
|
||||||
|
retry: 1
|
||||||
|
});
|
||||||
|
|
||||||
const accounts: HeaderAccount[] = [{
|
const { ref } = useFocusable({ focusKey: 'accounts' });
|
||||||
id: 'romm', previewUrl: [
|
|
||||||
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
|
||||||
],
|
|
||||||
action: () =>
|
|
||||||
{
|
|
||||||
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
|
||||||
},
|
|
||||||
status: user.data ? "status-success" : 'status-error',
|
|
||||||
type: 'secondary'
|
|
||||||
}, ...data.accounts ?? []];
|
|
||||||
|
|
||||||
return <div className="flex items-center gap-2 drop-shadow-sm">
|
const accounts: HeaderAccount[] = [];
|
||||||
|
if (data.accounts) accounts.push(...data.accounts);
|
||||||
|
|
||||||
|
if (rommUser.data)
|
||||||
|
{
|
||||||
|
accounts.push({
|
||||||
|
id: 'romm', preview: `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
||||||
|
action: () =>
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
||||||
|
},
|
||||||
|
status: rommUser.data ? "status-success" : 'status-error',
|
||||||
|
type: 'secondary'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twitchStatus.data)
|
||||||
|
{
|
||||||
|
accounts.push({
|
||||||
|
id: 'twitch', preview: TwitchIcon,
|
||||||
|
action: () =>
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
||||||
|
},
|
||||||
|
type: 'secondary'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={ref} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-hover ">
|
||||||
{accounts?.map(a => <HeaderAvatar
|
{accounts?.map(a => <HeaderAvatar
|
||||||
key={`header-avatar-${a.id}`}
|
key={`header-avatar-${a.id}`}
|
||||||
type={a.type}
|
|
||||||
id={`account-${a.id}`}
|
id={`account-${a.id}`}
|
||||||
status={a.status}
|
|
||||||
locked={a.locked}
|
locked={a.locked}
|
||||||
imageSrc={a.previewUrl}
|
preview={a.preview}
|
||||||
onSelect={a.action}
|
onSelect={a.action}
|
||||||
/>)}
|
/>)}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
@ -273,7 +267,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="header-icon sm:size-10 md:size-16"
|
className="header-icon sm:size-10 md:size-14"
|
||||||
id={b.id}
|
id={b.id}
|
||||||
external={b.external}
|
external={b.external}
|
||||||
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
|
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
|
||||||
|
|
@ -296,6 +290,10 @@ interface HeaderUIParams
|
||||||
export function HeaderUI (data: HeaderUIParams)
|
export function HeaderUI (data: HeaderUIParams)
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
|
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
|
||||||
|
const goToSettings = () =>
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/settings/accounts' });
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<header
|
<header
|
||||||
|
|
@ -305,7 +303,7 @@ export function HeaderUI (data: HeaderUIParams)
|
||||||
>
|
>
|
||||||
<HeaderAccounts accounts={data.accounts} />
|
<HeaderAccounts accounts={data.accounts} />
|
||||||
{data.title}
|
{data.title}
|
||||||
<HeaderStatusBar buttonElements={data.buttonElements} buttons={data.buttons} />
|
<HeaderStatusBar buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
||||||
</header>
|
</header>
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { FOCUS_KEYS } from "../scripts/types";
|
import { FOCUS_KEYS } from "../scripts/types";
|
||||||
import { useIntersectionObserver } from "usehooks-ts";
|
import { useIntersectionObserver } from "usehooks-ts";
|
||||||
import { FrontEndId } from "@/shared/constants";
|
|
||||||
|
|
||||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export default function NotFound ()
|
||||||
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||||
<div className="mobile:hidden bg-gradient"></div>
|
<div className="mobile:hidden bg-gradient"></div>
|
||||||
<div className="mobile:hidden bg-noise"></div>
|
<div className="mobile:hidden bg-noise"></div>
|
||||||
|
<div className="mobile:hidden bg-dots"></div>
|
||||||
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Notification, RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import toast, { ToastOptions } from "react-hot-toast";
|
import toast, { ToastOptions } from "react-hot-toast";
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default function Notifications (data: {})
|
||||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/system/notifications`);
|
const es = new EventSource(`${RPC_URL(__HOST__)}/api/system/notifications`);
|
||||||
es.addEventListener('notification', (e) =>
|
es.addEventListener('notification', (e) =>
|
||||||
{
|
{
|
||||||
const notification = JSON.parse(e.data) as Notification;
|
const notification = JSON.parse(e.data) as FrontendNotification;
|
||||||
const options: ToastOptions = { removeDelay: notification.duration };
|
const options: ToastOptions = { removeDelay: notification.duration };
|
||||||
if (notification.type === 'error')
|
if (notification.type === 'error')
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
||||||
}
|
}
|
||||||
}); 4096;
|
}); 4096;
|
||||||
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
||||||
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />
|
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
|
||||||
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.(e.nativeEvent)}> <Fullscreen /> </div>
|
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.(e.nativeEvent)}> <Fullscreen /> </div>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,48 +3,48 @@ import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
|
||||||
import ShortcutPrompt from './ShortcutPrompt';
|
import ShortcutPrompt from './ShortcutPrompt';
|
||||||
import { IconType } from './SvgIcon';
|
import { IconType } from './SvgIcon';
|
||||||
|
|
||||||
const iconMap: Record<GamePadButtonCode, IconType> = {
|
|
||||||
[GamePadButtonCode.A]: 'steamdeck_button_a',
|
|
||||||
[GamePadButtonCode.B]: 'steamdeck_button_b',
|
|
||||||
[GamePadButtonCode.X]: 'steamdeck_button_x',
|
|
||||||
[GamePadButtonCode.Y]: 'steamdeck_button_y',
|
|
||||||
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
|
|
||||||
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
|
|
||||||
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
|
|
||||||
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
|
|
||||||
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
|
|
||||||
[GamePadButtonCode.Start]: 'steamdeck_button_options',
|
|
||||||
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
|
|
||||||
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
|
|
||||||
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
|
|
||||||
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
|
|
||||||
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
|
|
||||||
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
|
|
||||||
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyboardMap: Record<GamePadButtonCode, string> = {
|
|
||||||
[GamePadButtonCode.A]: 'ENTER',
|
|
||||||
[GamePadButtonCode.B]: 'ESC',
|
|
||||||
[GamePadButtonCode.X]: 'BACKSPACE',
|
|
||||||
[GamePadButtonCode.Y]: 'SPACE',
|
|
||||||
[GamePadButtonCode.L1]: 'Q',
|
|
||||||
[GamePadButtonCode.R1]: 'E',
|
|
||||||
[GamePadButtonCode.L2]: '',
|
|
||||||
[GamePadButtonCode.R2]: '',
|
|
||||||
[GamePadButtonCode.Select]: '',
|
|
||||||
[GamePadButtonCode.Start]: '',
|
|
||||||
[GamePadButtonCode.LJoy]: '',
|
|
||||||
[GamePadButtonCode.RJoy]: '',
|
|
||||||
[GamePadButtonCode.Up]: '',
|
|
||||||
[GamePadButtonCode.Down]: '',
|
|
||||||
[GamePadButtonCode.Left]: '',
|
|
||||||
[GamePadButtonCode.Right]: '',
|
|
||||||
[GamePadButtonCode.Steam]: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
||||||
{
|
{
|
||||||
|
const iconMap: Record<GamePadButtonCode, IconType> = {
|
||||||
|
[GamePadButtonCode.A]: 'steamdeck_button_a',
|
||||||
|
[GamePadButtonCode.B]: 'steamdeck_button_b',
|
||||||
|
[GamePadButtonCode.X]: 'steamdeck_button_x',
|
||||||
|
[GamePadButtonCode.Y]: 'steamdeck_button_y',
|
||||||
|
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
|
||||||
|
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
|
||||||
|
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
|
||||||
|
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
|
||||||
|
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
|
||||||
|
[GamePadButtonCode.Start]: 'steamdeck_button_options',
|
||||||
|
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
|
||||||
|
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
|
||||||
|
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
|
||||||
|
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
|
||||||
|
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
|
||||||
|
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
|
||||||
|
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardMap: Record<GamePadButtonCode, string> = {
|
||||||
|
[GamePadButtonCode.A]: 'ENTER',
|
||||||
|
[GamePadButtonCode.B]: 'ESC',
|
||||||
|
[GamePadButtonCode.X]: 'BACKSPACE',
|
||||||
|
[GamePadButtonCode.Y]: 'SPACE',
|
||||||
|
[GamePadButtonCode.L1]: 'Q',
|
||||||
|
[GamePadButtonCode.R1]: 'E',
|
||||||
|
[GamePadButtonCode.L2]: '',
|
||||||
|
[GamePadButtonCode.R2]: '',
|
||||||
|
[GamePadButtonCode.Select]: '',
|
||||||
|
[GamePadButtonCode.Start]: '',
|
||||||
|
[GamePadButtonCode.LJoy]: '',
|
||||||
|
[GamePadButtonCode.RJoy]: '',
|
||||||
|
[GamePadButtonCode.Up]: '',
|
||||||
|
[GamePadButtonCode.Down]: '',
|
||||||
|
[GamePadButtonCode.Left]: '',
|
||||||
|
[GamePadButtonCode.Right]: '',
|
||||||
|
[GamePadButtonCode.Steam]: ''
|
||||||
|
};
|
||||||
|
|
||||||
const { control } = useActiveControl();
|
const { control } = useActiveControl();
|
||||||
const showKeyboard = control === 'keyboard' || control === 'mouse';
|
const showKeyboard = control === 'keyboard' || control === 'mouse';
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/constants";
|
|
||||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { Medal } from "lucide-react";
|
import { Medal } from "lucide-react";
|
||||||
|
|
||||||
|
|
|
||||||
42
src/mainview/components/game/ActionButton.tsx
Normal file
42
src/mainview/components/game/ActionButton.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { JSX } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export default function ActionButton (data: {
|
||||||
|
id: string,
|
||||||
|
icon?: JSX.Element,
|
||||||
|
children?: any | any[];
|
||||||
|
className?: string;
|
||||||
|
type: "primary" | 'base' | "accent" | 'error';
|
||||||
|
square?: boolean,
|
||||||
|
onFocus?: () => void;
|
||||||
|
tooltip?: string,
|
||||||
|
tooltip_type?: 'accent' | 'error';
|
||||||
|
onAction?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||||
|
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-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 (
|
||||||
|
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
|
||||||
|
<button
|
||||||
|
disabled={data.disabled}
|
||||||
|
ref={ref}
|
||||||
|
onClick={data.onAction}
|
||||||
|
data-tooltip={data.tooltip}
|
||||||
|
data-tooltip_type={data.tooltip_type}
|
||||||
|
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
||||||
|
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||||
|
{data.icon}
|
||||||
|
{data.children}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/mainview/components/game/ActionButtons.tsx
Normal file
84
src/mainview/components/game/ActionButtons.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { deleteGameMutation } from "@/mainview/scripts/queries/romm";
|
||||||
|
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||||
|
import { getErrorMessage } from "react-error-boundary";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { Settings, Trash, Trophy } from "lucide-react";
|
||||||
|
import MainActions from "./MainActions";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
import FocusTooltip from "../FocusTooltip";
|
||||||
|
|
||||||
|
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
|
||||||
|
{
|
||||||
|
if (!data.game.achievements)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`}
|
||||||
|
</div>
|
||||||
|
<progress className="progress progress-secondary w-full" value={data.game.achievements.unlocked / data.game.achievements.total} max="1"></progress>
|
||||||
|
</div>
|
||||||
|
</ActionButton>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionButtons (data: { game: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||||
|
{
|
||||||
|
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
||||||
|
|
||||||
|
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', trackChildren: true });
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
...deleteGameMutation(data.game.id),
|
||||||
|
onSuccess: () =>
|
||||||
|
{
|
||||||
|
location.reload();
|
||||||
|
console.log("Deleted");
|
||||||
|
},
|
||||||
|
onError (error)
|
||||||
|
{
|
||||||
|
toast.error(getErrorMessage(error) ?? "Error While Deleting");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextOptions: DialogEntry[] = [];
|
||||||
|
if (data.game.local)
|
||||||
|
{
|
||||||
|
contextOptions.push({
|
||||||
|
id: 'delete',
|
||||||
|
action: () =>
|
||||||
|
{
|
||||||
|
deleteMutation.mutate();
|
||||||
|
},
|
||||||
|
icon: <Trash />,
|
||||||
|
content: "Delete",
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList options={contextOptions} /> });
|
||||||
|
|
||||||
|
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} source={data.source} id={data.id} />
|
||||||
|
<AchievementsInfo game={data.game} onAction={() =>
|
||||||
|
{
|
||||||
|
setDetailsSection("achievements");
|
||||||
|
if (data.game.achievements?.entires[0])
|
||||||
|
{
|
||||||
|
setFocus(data.game.achievements.entires[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
}} />
|
||||||
|
<ActionButton tooltip="Settings" onAction={() => setOpen(true, 'settings')} type="base" id="settings" icon={<Settings />} >
|
||||||
|
</ActionButton >
|
||||||
|
{settingsDialog}
|
||||||
|
<FocusTooltip visible={hasFocusedChild} parentRef={ref} />
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
95
src/mainview/components/game/Details.tsx
Normal file
95
src/mainview/components/game/Details.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
|
||||||
|
import { RPC_URL } from "@/shared/constants";
|
||||||
|
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { JSX } from "react";
|
||||||
|
import ActionButtons from "./ActionButtons";
|
||||||
|
|
||||||
|
|
||||||
|
export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; })
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{data.icon}
|
||||||
|
{data.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Details (data: {
|
||||||
|
game?: FrontEndGameTypeDetailed,
|
||||||
|
source: string,
|
||||||
|
id: string;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const { ref, focusKey } = useFocusable({
|
||||||
|
focusKey: 'main-details',
|
||||||
|
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'end', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||||
|
preferredChildFocusKey: "play-btn",
|
||||||
|
saveLastFocusedChild: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined;
|
||||||
|
if (platformCoverImg)
|
||||||
|
platformCoverImg.searchParams.set("width", "64");
|
||||||
|
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
|
||||||
|
|
||||||
|
let fileSizeIcon: JSX.Element | undefined;
|
||||||
|
if (!data.game)
|
||||||
|
{
|
||||||
|
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
||||||
|
} else if (data.game.missing)
|
||||||
|
{
|
||||||
|
fileSizeIcon = <TriangleAlert />;
|
||||||
|
} else if (data.game.local)
|
||||||
|
{
|
||||||
|
fileSizeIcon = <HardDrive />;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
fileSizeIcon = <CloudDownload />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||||
|
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
||||||
|
{gameCoverImg ?
|
||||||
|
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
|
||||||
|
<div className="skeleton w-full h-full"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
||||||
|
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
||||||
|
<DetailElement icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</DetailElement>
|
||||||
|
{!!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}>
|
||||||
|
<DetailElement icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</DetailElement>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
|
||||||
|
<DetailElement icon={
|
||||||
|
<Store />
|
||||||
|
} >
|
||||||
|
{data.game?.source ?? data.game?.id.source}
|
||||||
|
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</DetailElement>
|
||||||
|
</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">
|
||||||
|
<div className="skeleton h-4 w-[30%]"></div>
|
||||||
|
<div className="skeleton h-4 w-[80%]"></div>
|
||||||
|
<div className="skeleton h-4 w-full"></div>
|
||||||
|
<div className="skeleton h-4 w-[60%]"></div>
|
||||||
|
<div className="skeleton h-4 w-full"></div>
|
||||||
|
<div className="skeleton h-4 w-[80%]"></div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
{!!data.game && <ActionButtons source={data.source} id={data.id} game={data.game} key="actions" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</FocusContext>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
207
src/mainview/components/game/MainActions.tsx
Normal file
207
src/mainview/components/game/MainActions.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { Router } from "@/mainview";
|
||||||
|
import { rommApi } from "@/mainview/scripts/clientApi";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { JSX, useEffect, useRef, useState } from "react";
|
||||||
|
import { getErrorMessage } from "react-error-boundary";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||||
|
import { Clock, Download, EllipsisVertical, PackageOpen, Play, TriangleAlert } from "lucide-react";
|
||||||
|
import { installMutation, playMutation } from "@/mainview/scripts/queries/romm";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
|
||||||
|
export default function MainActions (data: { game: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||||
|
{
|
||||||
|
const installMut = useMutation(installMutation(data.source, data.id));
|
||||||
|
const playMut = useMutation({
|
||||||
|
...playMutation, onError (error)
|
||||||
|
{
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
onSuccess (data, { source, id }, onMutateResult, context)
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ws = useRef<{ send: (data: string) => void; }>(undefined);
|
||||||
|
const [progress, setProgress] = useState<number | 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.game.source ?? data.game.id.source}-${data.game.source_id ?? data.game.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 sub = rommApi.api.romm.status({ source: data.game.id.source })({ id: data.game.id.id }).subscribe();
|
||||||
|
ws.current = sub.ws;
|
||||||
|
|
||||||
|
sub.subscribe((e) =>
|
||||||
|
{
|
||||||
|
setStatus(e.data.status);
|
||||||
|
setProgress((e.data as any).progress);
|
||||||
|
setDetails((e.data as any).details);
|
||||||
|
setCommands((e.data as any).commands);
|
||||||
|
|
||||||
|
if (e.data.status === 'refresh')
|
||||||
|
{
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
|
||||||
|
Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true });
|
||||||
|
} else if (e.data.status === 'error')
|
||||||
|
{
|
||||||
|
const errorMessage = getErrorMessage(e.data.error);
|
||||||
|
if (!errorMessage) return;
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
sub.close();
|
||||||
|
ws.current = undefined;
|
||||||
|
};
|
||||||
|
}, [data.game.id]);
|
||||||
|
|
||||||
|
let progressIcon: JSX.Element | undefined = undefined;
|
||||||
|
switch (status)
|
||||||
|
{
|
||||||
|
case 'download':
|
||||||
|
progressIcon = <Download />;
|
||||||
|
break;
|
||||||
|
case 'queued':
|
||||||
|
progressIcon = <Clock />;
|
||||||
|
break;
|
||||||
|
case 'extract':
|
||||||
|
progressIcon = <PackageOpen />;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showProgress = progress !== null && !!progressIcon;
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (showProgress) return;
|
||||||
|
showInstallOptions(false);
|
||||||
|
}, [showProgress]);
|
||||||
|
|
||||||
|
const handlePlay = (cmd?: CommandEntry) =>
|
||||||
|
{
|
||||||
|
if (!cmd) return;
|
||||||
|
if (cmd.emulator === 'EMULATORJS')
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams(cmd.command);
|
||||||
|
Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
playMut.mutate({ source: data.game.id.source, id: data.game.id.id, command_id: cmd.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
mainButton = <ActionButton
|
||||||
|
key="error"
|
||||||
|
tooltip={error}
|
||||||
|
tooltip_type="error"
|
||||||
|
type='error'
|
||||||
|
onAction={() =>
|
||||||
|
{
|
||||||
|
if (status === 'missing-emulator')
|
||||||
|
{
|
||||||
|
Router.navigate({ to: '/settings/directories' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id="mainAction">
|
||||||
|
<TriangleAlert />
|
||||||
|
</ActionButton>;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mainButton = <ActionButton
|
||||||
|
key={status ?? 'unknown'}
|
||||||
|
disabled={installMut.isPending}
|
||||||
|
onAction={() =>
|
||||||
|
{
|
||||||
|
if (status === 'install')
|
||||||
|
{
|
||||||
|
installMut.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tooltip={details ?? status}
|
||||||
|
type='primary'
|
||||||
|
id="mainAction">
|
||||||
|
{status === 'install' ? <Download /> : <span className="loading loading-spinner loading-lg"></span>}
|
||||||
|
</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>
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</ActionButton>}
|
||||||
|
{installOptionsDialog}
|
||||||
|
{allCommandDialog}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -11,14 +11,14 @@ import { CSSProperties } from "react";
|
||||||
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
|
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
base: 'bg-base-200 text-base-content active:bg-base-300! active:text-base-content! active:ring-offset-base-content',
|
base: 'dark:bg-base-200 light:bg-base-300 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content',
|
||||||
accent: "bg-accent text-accent-content active:bg-base-content! active:text-base-content active:ring-offset-accent",
|
accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent",
|
||||||
primary: "bg-primary text-primary-content active:bg-base-content! active:text-base-content! active:ring-offset-primary",
|
primary: "bg-primary text-primary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-primary",
|
||||||
secondary: "bg-secondary text-secondary-content active:bg-base-content! active:text-base-content! active:ring-offset-secondary",
|
secondary: "bg-secondary text-secondary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary",
|
||||||
info: "bg-info text-info-content active:bg-base-content! active:text-base-content! active:ring-offset-info",
|
info: "bg-info text-info-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-info",
|
||||||
success: "bg-success text-success-content active:bg-base-content! active:text-base-content! active:ring-offset-success",
|
success: "bg-success text-success-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-success",
|
||||||
warning: "bg-warning text-warning-content active:bg-base-content! active:text-base-content! active:ring-offset-warning",
|
warning: "bg-warning text-warning-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-warning",
|
||||||
error: "bg-error text-error-content active:bg-base-content! active:text-base-content! active:ring-offset-error",
|
error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button (data: {
|
export function Button (data: {
|
||||||
|
|
@ -31,6 +31,8 @@ export function Button (data: {
|
||||||
shortcutLabel?: string;
|
shortcutLabel?: string;
|
||||||
focusClassName?: string;
|
focusClassName?: string;
|
||||||
cssStyle?: CSSProperties;
|
cssStyle?: CSSProperties;
|
||||||
|
tooltip?: string;
|
||||||
|
tooltipType?: "base" | "accent" | "error";
|
||||||
} & InteractParams & FocusParams)
|
} & InteractParams & FocusParams)
|
||||||
{
|
{
|
||||||
const { ref, focused, focusKey } = useFocusable({
|
const { ref, focused, focusKey } = useFocusable({
|
||||||
|
|
@ -49,8 +51,10 @@ export function Button (data: {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={e => data.onAction?.(e.nativeEvent)}
|
onClick={e => data.onAction?.(e.nativeEvent)}
|
||||||
disabled={data.disabled}
|
disabled={data.disabled}
|
||||||
|
data-tooltip={data.tooltip}
|
||||||
|
data-tooltip_type={data.tooltipType}
|
||||||
style={data.cssStyle}
|
style={data.cssStyle}
|
||||||
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:bg-base-content control-mouse:hover:text-base-100 active:transition-none active:ring-offset-4",
|
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4",
|
||||||
styles[data.style ?? 'base'],
|
styles[data.style ?? 'base'],
|
||||||
focused ? data.focusClassName : undefined,
|
focused ? data.focusClassName : undefined,
|
||||||
classNames({
|
classNames({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge";
|
||||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
|
|
||||||
export function OptionDropdown (data: {
|
export function OptionDropdown (data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -38,7 +39,7 @@ export function OptionDropdown (data: {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
|
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
|
||||||
</label>
|
</label>
|
||||||
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
|
{open && <ContextDialog id={`${data.name}-context`} preferredChildFocusKey={FOCUS_KEYS.CONTEXT_DIALOG_OPTION(`${data.name}-context`, String(data.values.indexOf(data.value ?? '')))} open={true} close={handleClose}>
|
||||||
<ContextList options={data.values.map((v, i) => ({
|
<ContextList options={data.values.map((v, i) => ({
|
||||||
content: v,
|
content: v,
|
||||||
id: String(i),
|
id: String(i),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function OptionInput (data: {
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
value?: string;
|
value?: string | boolean;
|
||||||
defaultValue?: string | boolean;
|
defaultValue?: string | boolean;
|
||||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
|
@ -58,7 +58,7 @@ export function OptionInput (data: {
|
||||||
id={data.name}
|
id={data.name}
|
||||||
data-focus={"input"}
|
data-focus={"input"}
|
||||||
name={data.name}
|
name={data.name}
|
||||||
value={data.value}
|
value={String(data.value)}
|
||||||
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
|
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
|
||||||
type={data.type}
|
type={data.type}
|
||||||
autoComplete={data.autocomplete}
|
autoComplete={data.autocomplete}
|
||||||
|
|
@ -68,24 +68,22 @@ export function OptionInput (data: {
|
||||||
onBlur={data.onBlur}
|
onBlur={data.onBlur}
|
||||||
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex text-base-content px-4 py-2 items-center justify-center border border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-200 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
|
"flex text-base-content px-4 py-2 items-center justify-center border bg-base-200 border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-100 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
|
||||||
data.className
|
data.className
|
||||||
)}
|
)}
|
||||||
/>}
|
/>}
|
||||||
{data.type === 'checkbox' && <div className="toggle toggle-xl before:size-6 h-8 border-base-content/30 rounded-full before:rounded-full text-base-content not-in-focus:bg-base-200 focused-child:border-0 ml-1 ring-7 hover:border-base-content focusable focusable-accent">
|
{data.type === 'checkbox' && <div className="toggle toggle-xl toggle-success before:size-6 h-8 border-base-content/30 rounded-full before:bg-base-100 before:rounded-full text-base-content not-in-focus:bg-base-200 focused-child:border-0 ml-1 ring-7 hover:border-base-content focusable has-checked:bg-success not-has-checked:bg-error">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={data.name}
|
id={data.name}
|
||||||
name={data.name}
|
name={data.name}
|
||||||
value={data.value}
|
checked={Boolean(data.value)}
|
||||||
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
|
|
||||||
type={data.type}
|
type={data.type}
|
||||||
autoComplete={data.autocomplete}
|
autoComplete={data.autocomplete}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
placeholder={data.placeholder}
|
placeholder={data.placeholder}
|
||||||
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
|
onChange={e => data.onChange?.(e.target.checked)}
|
||||||
onBlur={data.onBlur}
|
onBlur={data.onBlur}
|
||||||
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
data.className
|
data.className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,6 @@ import FilePicker from "../FilePicker";
|
||||||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||||
|
|
||||||
type KeysWithValueAssignableTo<T, Value> = {
|
|
||||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
export interface PathSettingsOptionParams
|
export interface PathSettingsOptionParams
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -68,11 +64,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if (!data.isDirty)
|
data.setLocalValue(String(defaultValue));
|
||||||
{
|
}, [defaultValue]);
|
||||||
data.setLocalValue(String(defaultValue));
|
|
||||||
}
|
|
||||||
}, [data.isDirty, defaultValue]);
|
|
||||||
|
|
||||||
const handleSelectPath = (path: string) =>
|
const handleSelectPath = (path: string) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react";
|
||||||
import { SettingsType } from "../../../shared/constants";
|
import { SettingsType } from "../../../shared/constants";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { OptionSpace } from "./OptionSpace";
|
import { OptionSpace } from "./OptionSpace";
|
||||||
import { OptionInput } from "./OptionInput";
|
import { OptionInput } from "./OptionInput";
|
||||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||||
|
|
||||||
type KeysWithValueAssignableTo<T, Value> = {
|
|
||||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
export function SettingsOption (data: {
|
export function SettingsOption (data: {
|
||||||
label: string;
|
label: string;
|
||||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
id: KeysWithValueAssignableTo<SettingsType, string | boolean>;
|
||||||
type: HTMLInputTypeAttribute;
|
type: HTMLInputTypeAttribute;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
|
|
@ -19,10 +15,16 @@ export function SettingsOption (data: {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | boolean | undefined>();
|
||||||
useQuery(getSettingQuery(data.id));
|
const { data: serverValue } = useQuery(getSettingQuery(data.id));
|
||||||
const setMutation = useMutation(setSettingMutation(data.id));
|
const setMutation = useMutation(setSettingMutation(data.id));
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setLocalValue(serverValue as any);
|
||||||
|
setDirty(false);
|
||||||
|
}, [serverValue]);
|
||||||
|
|
||||||
const handleSave = useCallback(() =>
|
const handleSave = useCallback(() =>
|
||||||
{
|
{
|
||||||
if (dirty)
|
if (dirty)
|
||||||
|
|
@ -43,7 +45,14 @@ export function SettingsOption (data: {
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
{
|
{
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
setDirty(true);
|
|
||||||
|
if (data.type === 'checkbox')
|
||||||
|
{
|
||||||
|
setMutation.mutate(v);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
value={localValue}
|
value={localValue}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import FocusDots from "../FocusDots";
|
||||||
import { Router } from "@/mainview";
|
import { Router } from "@/mainview";
|
||||||
import { StoreEmulatorCard } from "./StoreEmulatorCard";
|
import { StoreEmulatorCard } from "./StoreEmulatorCard";
|
||||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
import { FrontEndEmulator } from "@/shared/constants";
|
|
||||||
import Carousel from "../Carousel";
|
import Carousel from "../Carousel";
|
||||||
|
|
||||||
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
|
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
|
||||||
|
|
@ -51,18 +50,18 @@ export function EmulatorsSection (data: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<section ref={ref} className="px-2 py-4">
|
<section ref={ref} className="px-2 py-4 pb-0">
|
||||||
<div className="flex items-center gap-3 px-4 mb-4 text-info">
|
<div className="flex items-center gap-3 px-4 mb-4 text-info">
|
||||||
{data.header ?? <>
|
{data.header ?? <>
|
||||||
<div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
<div className="w-2 h-5 rounded-full bg-info shadow-sm" />
|
||||||
<Joystick />
|
<Joystick className="shadow-sm" />
|
||||||
<h2 className="font-bold uppercase tracking-widest">
|
<h2 className="font-bold uppercase tracking-widest text-shadow-sm">
|
||||||
Recommended Emulators
|
Recommended Emulators
|
||||||
</h2>
|
</h2>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
|
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 pb-4 px-4 gap-4 select-none">
|
||||||
{data.emulators?.map((em) => (
|
{data.emulators?.map((em) => (
|
||||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react";
|
import { Ref, useEffect, useRef } from "react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
useFocusable,
|
useFocusable,
|
||||||
|
|
@ -6,7 +6,6 @@ import
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||||
import FocusDots from "../FocusDots";
|
import FocusDots from "../FocusDots";
|
||||||
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
|
||||||
import FrontEndGameCard from "../FrontEndGameCard";
|
import FrontEndGameCard from "../FrontEndGameCard";
|
||||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
import Carousel from "../Carousel";
|
import Carousel from "../Carousel";
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Button } from "../options/Button";
|
||||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||||
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
|
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
|
||||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
|
|
||||||
// ── Single missing-emulator card ───────────────────────────────────────────
|
// ── Single missing-emulator card ───────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import { Button } from "../options/Button";
|
import { Button } from "../options/Button";
|
||||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import { ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Store } from "lucide-react";
|
import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react";
|
||||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||||
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
|
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
|
|
@ -54,14 +54,13 @@ export function StoreEmulatorCard (data: {
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div
|
<div
|
||||||
data-installed={!!data.emulator.validSource}
|
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg in-data-[installed=true]:bg-success`}
|
||||||
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
|
|
||||||
>
|
>
|
||||||
<img draggable={false} src={data.emulator.logo}></img>
|
<img draggable={false} src={data.emulator.logo}></img>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p data-installed={!!data.emulator.validSource} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
<p className="font-bold text-base-content text-xl leading-snug in-data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||||
<ul className="flex flex-wrap gap-1">
|
<ul className="flex flex-wrap gap-1">
|
||||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||||
{
|
{
|
||||||
|
|
@ -75,15 +74,15 @@ export function StoreEmulatorCard (data: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
<div className="flex gap-1 mt-1 h-10 items-center">
|
||||||
|
{!!data.emulator.integration && data.emulator.validSource?.type === 'store' && <div className="tooltip tooltip-primary" data-tip="Has Integration">
|
||||||
|
<div className="bg-primary text-primary-content rounded-full p-1"><WandSparkles /></div>
|
||||||
|
</div>}
|
||||||
{!!data.emulator.validSource && <div className="tooltip" data-tip={data.emulator.validSource.type}>
|
{!!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">
|
<div data-source={data.emulator.validSource?.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-warning text-warning-content data-[source=store]:bg-success data-[source=store]:text-success-content">
|
||||||
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
|
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
{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 && <>
|
{isMouse && <>
|
||||||
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
|
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
|
||||||
<Button className="bg-transparent border-none shadow-none w-6 p-0" id={`${data.emulator.name}-options`} ><EllipsisVertical /></Button>
|
<Button className="bg-transparent border-none shadow-none w-6 p-0" id={`${data.emulator.name}-options`} ><EllipsisVertical /></Button>
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,4 @@ const moduleUrls = import.meta.glob
|
||||||
// emulatorjs expects basenames instead of paths for some reason
|
// emulatorjs expects basenames instead of paths for some reason
|
||||||
window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()])));
|
window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()])));
|
||||||
|
|
||||||
await import('@emulatorjs/emulatorjs/data/loader.js');
|
await import('@emulatorjs/emulatorjs/data/loader.js' as any);
|
||||||
|
|
@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './../routes/__root'
|
||||||
import { Route as GamesRouteImport } from './../routes/games'
|
import { Route as GamesRouteImport } from './../routes/games'
|
||||||
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
||||||
import { Route as IndexRouteImport } from './../routes/index'
|
import { Route as IndexRouteImport } from './../routes/index'
|
||||||
|
import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins'
|
||||||
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
|
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
|
||||||
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
|
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
|
||||||
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
|
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
|
||||||
|
|
@ -43,6 +44,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SettingsPluginsRoute = SettingsPluginsRouteImport.update({
|
||||||
|
id: '/plugins',
|
||||||
|
path: '/plugins',
|
||||||
|
getParentRoute: () => SettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({
|
const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({
|
||||||
id: '/interface',
|
id: '/interface',
|
||||||
path: '/interface',
|
path: '/interface',
|
||||||
|
|
@ -130,6 +136,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||||
'/settings/interface': typeof SettingsInterfaceRoute
|
'/settings/interface': typeof SettingsInterfaceRoute
|
||||||
|
'/settings/plugins': typeof SettingsPluginsRoute
|
||||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
|
|
@ -149,6 +156,7 @@ export interface FileRoutesByTo {
|
||||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||||
'/settings/interface': typeof SettingsInterfaceRoute
|
'/settings/interface': typeof SettingsInterfaceRoute
|
||||||
|
'/settings/plugins': typeof SettingsPluginsRoute
|
||||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
|
|
@ -170,6 +178,7 @@ export interface FileRoutesById {
|
||||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||||
'/settings/interface': typeof SettingsInterfaceRoute
|
'/settings/interface': typeof SettingsInterfaceRoute
|
||||||
|
'/settings/plugins': typeof SettingsPluginsRoute
|
||||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
|
|
@ -192,6 +201,7 @@ export interface FileRouteTypes {
|
||||||
| '/settings/directories'
|
| '/settings/directories'
|
||||||
| '/settings/emulators'
|
| '/settings/emulators'
|
||||||
| '/settings/interface'
|
| '/settings/interface'
|
||||||
|
| '/settings/plugins'
|
||||||
| '/embedded/$source/$id'
|
| '/embedded/$source/$id'
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
|
|
@ -211,6 +221,7 @@ export interface FileRouteTypes {
|
||||||
| '/settings/directories'
|
| '/settings/directories'
|
||||||
| '/settings/emulators'
|
| '/settings/emulators'
|
||||||
| '/settings/interface'
|
| '/settings/interface'
|
||||||
|
| '/settings/plugins'
|
||||||
| '/embedded/$source/$id'
|
| '/embedded/$source/$id'
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
|
|
@ -231,6 +242,7 @@ export interface FileRouteTypes {
|
||||||
| '/settings/directories'
|
| '/settings/directories'
|
||||||
| '/settings/emulators'
|
| '/settings/emulators'
|
||||||
| '/settings/interface'
|
| '/settings/interface'
|
||||||
|
| '/settings/plugins'
|
||||||
| '/embedded/$source/$id'
|
| '/embedded/$source/$id'
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
|
|
@ -277,6 +289,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/settings/plugins': {
|
||||||
|
id: '/settings/plugins'
|
||||||
|
path: '/plugins'
|
||||||
|
fullPath: '/settings/plugins'
|
||||||
|
preLoaderRoute: typeof SettingsPluginsRouteImport
|
||||||
|
parentRoute: typeof SettingsRouteRoute
|
||||||
|
}
|
||||||
'/settings/interface': {
|
'/settings/interface': {
|
||||||
id: '/settings/interface'
|
id: '/settings/interface'
|
||||||
path: '/interface'
|
path: '/interface'
|
||||||
|
|
@ -391,6 +410,7 @@ interface SettingsRouteRouteChildren {
|
||||||
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
|
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
|
||||||
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
||||||
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
||||||
|
SettingsPluginsRoute: typeof SettingsPluginsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||||
|
|
@ -399,6 +419,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||||
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
|
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
|
||||||
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
||||||
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
||||||
|
SettingsPluginsRoute: SettingsPluginsRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
@custom-variant light (&:where([data-theme=light], [data-theme=light] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--breakpoint-sm: 0px;
|
--breakpoint-sm: 0px;
|
||||||
|
|
@ -194,6 +195,7 @@ html {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -344,18 +346,21 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
background-repeat: repeat;
|
||||||
--bg-gradient-opacity: 15%;
|
--bg-gradient-opacity: 15%;
|
||||||
|
|
||||||
background:
|
@variant dark {
|
||||||
radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
background:
|
||||||
radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||||
radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||||
radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%);
|
radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||||
|
radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
background-blend-mode: lighten;
|
@variant light {
|
||||||
background-repeat: repeat;
|
background-color: var(--color-base-300);
|
||||||
background-color: var(--color-base-100);
|
}
|
||||||
@apply mobile:hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-noise {
|
.bg-noise {
|
||||||
|
|
@ -368,6 +373,26 @@ body {
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-dots {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background-image: radial-gradient(var(--color-neutral) 0.1rem, transparent 0.1rem);
|
||||||
|
background-size: 2rem 2rem;
|
||||||
|
background-position: -1rem -1rem;
|
||||||
|
|
||||||
|
@variant dark {
|
||||||
|
opacity: 0.5;
|
||||||
|
@apply mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@variant light {
|
||||||
|
opacity: 0.3;
|
||||||
|
@apply mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bg-gradient-back {
|
.bg-gradient-back {
|
||||||
|
|
||||||
--bg-opacity: 90%;
|
--bg-opacity: 90%;
|
||||||
|
|
@ -407,22 +432,22 @@ body {
|
||||||
html:active-view-transition-type(zoom-in) {
|
html:active-view-transition-type(zoom-in) {
|
||||||
|
|
||||||
&::view-transition-old(root) {
|
&::view-transition-old(root) {
|
||||||
animation: fade-out 300ms ease-in forwards;
|
animation: fade-out 200ms ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(root) {
|
&::view-transition-new(root) {
|
||||||
animation: zoom-in-fade-in 300ms ease-in-out forwards;
|
animation: zoom-in-fade-in 200ms ease-out forwards;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html:active-view-transition-type(zoom-out) {
|
html:active-view-transition-type(zoom-out) {
|
||||||
|
|
||||||
&::view-transition-old(root) {
|
&::view-transition-old(root) {
|
||||||
animation: zoom-out-fade-out 300ms ease-in-out forwards;
|
animation: zoom-out-fade-out 200ms ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(root) {
|
&::view-transition-new(root) {
|
||||||
animation: zoom-start-small-in-fade-in 300ms ease-in-out forwards;
|
animation: zoom-start-small-in-fade-in 200ms ease-out forwards;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html data-theme="dark" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Notifications from "../components/Notifications";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
||||||
import useActiveControl from "../scripts/gamepads";
|
import useActiveControl from "../scripts/gamepads";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
|
|
@ -14,9 +15,24 @@ function RootComponent ()
|
||||||
const isMobile = mobileCheck();
|
const isMobile = mobileCheck();
|
||||||
const theme = useLocalSetting('theme');
|
const theme = useLocalSetting('theme');
|
||||||
const { control } = useActiveControl();
|
const { control } = useActiveControl();
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (theme === 'auto')
|
||||||
|
{
|
||||||
|
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
|
||||||
|
window.document.documentElement.dataset.theme = preferred;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
window.document.documentElement.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-theme={theme === 'auto' ? undefined : theme} data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
<div data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
||||||
import { CommandEntry, RPC_URL } from "@shared/constants";
|
import { RPC_URL } from "@shared/constants";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
|
||||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react";
|
||||||
import { Calendar, Clock, CloudDownload, Download, EllipsisVertical, Folder, Gamepad2, HardDrive, Image, Info, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
|
|
||||||
import { HeaderUI } from "../../components/Header";
|
import { HeaderUI } from "../../components/Header";
|
||||||
import prettyBytes from 'pretty-bytes';
|
|
||||||
import { useFocusEventListener } from "../../scripts/spatialNavigation";
|
|
||||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||||
import toast from "react-hot-toast";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Router } from "../..";
|
import { Router } from "../..";
|
||||||
import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog";
|
|
||||||
import Shortcuts from "../../components/Shortcuts";
|
import Shortcuts from "../../components/Shortcuts";
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import Screenshots from "@/mainview/components/Screenshots";
|
import Screenshots from "@/mainview/components/Screenshots";
|
||||||
import { HandleGoBack, scrollIntoViewHandler, 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 { FilterUI } from "@/mainview/components/Filters";
|
||||||
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
||||||
import { useIntersectionObserver, useLocalStorage } from "usehooks-ts";
|
import { useIntersectionObserver, useLocalStorage } from "usehooks-ts";
|
||||||
|
|
@ -25,19 +18,17 @@ import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import Achievements from "@/mainview/components/game/Achievements";
|
import Achievements from "@/mainview/components/game/Achievements";
|
||||||
import { getErrorMessage } from "react-error-boundary";
|
|
||||||
import { GameDetailsContext } from "@/mainview/scripts/contexts";
|
import { GameDetailsContext } from "@/mainview/scripts/contexts";
|
||||||
import { rommApi } from "@/mainview/scripts/clientApi";
|
import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm";
|
||||||
import { deleteGameMutation, gameQuery, gamesRecommendedBasedOnGameQuery, installMutation, playMutation } from "@queries/romm";
|
|
||||||
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||||
|
import Details, { DetailElement } from "@/mainview/components/game/Details";
|
||||||
|
|
||||||
export const Route = createFileRoute("/game/$source/$id")({
|
export const Route = createFileRoute("/game/$source/$id")({
|
||||||
loader: async ({ params, context }) =>
|
loader: async ({ params, context }) =>
|
||||||
{
|
{
|
||||||
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
|
context.queryClient.prefetchQuery(gameQuery(params.source, params.id));
|
||||||
return { data };
|
|
||||||
},
|
},
|
||||||
component: GameDetailsUI,
|
component: RouteComponent,
|
||||||
pendingComponent: GameDetailsUIPending,
|
pendingComponent: GameDetailsUIPending,
|
||||||
errorComponent: Error,
|
errorComponent: Error,
|
||||||
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
||||||
|
|
@ -92,13 +83,13 @@ function MainDetailsPending ()
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
||||||
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
||||||
<Detail icon={<Clock />} ></Detail>
|
<DetailElement icon={<Clock />} ></DetailElement>
|
||||||
<Detail icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></Detail>
|
<DetailElement icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></DetailElement>
|
||||||
<Detail icon={
|
<DetailElement icon={
|
||||||
<Store />
|
<Store />
|
||||||
} >
|
} >
|
||||||
|
|
||||||
</Detail>
|
</DetailElement>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||||
|
|
@ -155,9 +146,8 @@ function GameDetailsUIPending ()
|
||||||
</AnimatedBackground>;
|
</AnimatedBackground>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoreDetails (data: {})
|
function MoreDetails (data: { game: FrontEndGameTypeDetailed | undefined; })
|
||||||
{
|
{
|
||||||
const { data: game } = Route.useLoaderData();
|
|
||||||
const [details] = useDetailsSection();
|
const [details] = useDetailsSection();
|
||||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||||
focusKey: "game-more-details-section",
|
focusKey: "game-more-details-section",
|
||||||
|
|
@ -167,456 +157,41 @@ function MoreDetails (data: {})
|
||||||
|
|
||||||
return <div ref={ref} className="scroll-mt-[15vh]">
|
return <div ref={ref} className="scroll-mt-[15vh]">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<Divider rootFocusKey={focusKey} showShortcuts={hasFocusedChild} />
|
<Divider game={data.game} rootFocusKey={focusKey} showShortcuts={hasFocusedChild} />
|
||||||
<div className="bg-base-200 py-12 min-h-[80vh]">
|
<div className="bg-base-200 py-12 min-h-[80vh]">
|
||||||
<div key={details} className="h-full animate-slide-up">
|
<div key={details} className="h-full animate-slide-up">
|
||||||
{details === 'screenshots' && <div className="h-[60vh]"><Screenshots screenshots={game.paths_screenshots} /></div>}
|
{details === 'screenshots' && !!data.game && <div className="h-[60vh]"><Screenshots screenshots={data.game.paths_screenshots} /></div>}
|
||||||
{details === 'stats' && <Stats />}
|
{details === 'stats' && <Stats game={data.game} />}
|
||||||
{details === 'achievements' && <Achievements game={game} />}
|
{details === 'achievements' && !!data.game && <Achievements game={data.game} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>; })
|
function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
|
||||||
{
|
{
|
||||||
const { data: game } = Route.useLoaderData();
|
|
||||||
const { ref, focusKey } = useFocusable({
|
|
||||||
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__)}${game?.path_platform_cover ?? ''}`);
|
|
||||||
platformCoverImg.searchParams.set("width", "64");
|
|
||||||
const gameCoverImg = game?.path_cover ? `${RPC_URL(__HOST__)}${game?.path_cover}` : undefined;
|
|
||||||
|
|
||||||
let fileSizeIcon: JSX.Element | undefined;
|
|
||||||
if (!game)
|
|
||||||
{
|
|
||||||
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
|
||||||
} else if (game.missing)
|
|
||||||
{
|
|
||||||
fileSizeIcon = <TriangleAlert />;
|
|
||||||
} else if (game.local)
|
|
||||||
{
|
|
||||||
fileSizeIcon = <HardDrive />;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
fileSizeIcon = <CloudDownload />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
|
||||||
<FocusContext value={focusKey}>
|
|
||||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
|
||||||
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
|
||||||
{gameCoverImg ?
|
|
||||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
|
|
||||||
<div className="skeleton w-full h-full"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
|
||||||
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
|
||||||
<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>} >{game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
|
|
||||||
<Detail icon={
|
|
||||||
<Store />
|
|
||||||
} >
|
|
||||||
{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">
|
|
||||||
{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>
|
|
||||||
<div className="skeleton h-4 w-[60%]"></div>
|
|
||||||
<div className="skeleton h-4 w-full"></div>
|
|
||||||
<div className="skeleton h-4 w-[80%]"></div>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
{!!game && <ActionButtons key="actions" />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</FocusContext>
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AchievementsInfo (data: InteractParams)
|
|
||||||
{
|
|
||||||
const { data: game } = Route.useLoaderData();
|
|
||||||
if (!game.achievements)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 />
|
|
||||||
{`${game.achievements.unlocked}/${game.achievements.total}`}
|
|
||||||
</div>
|
|
||||||
<progress className="progress progress-secondary w-full" value={game.achievements.unlocked / game.achievements.total} max="1"></progress>
|
|
||||||
</div>
|
|
||||||
</ActionButton>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainActions ()
|
|
||||||
{
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
const { source, id } = Route.useParams();
|
|
||||||
const installMut = useMutation(installMutation(source, id));
|
|
||||||
const playMut = useMutation({
|
|
||||||
...playMutation, onError (error)
|
|
||||||
{
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const ws = useRef<{ send: (data: string) => void; }>(undefined);
|
|
||||||
const [progress, setProgress] = useState<number | 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 sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
|
|
||||||
ws.current = sub.ws;
|
|
||||||
|
|
||||||
sub.subscribe((e) =>
|
|
||||||
{
|
|
||||||
setStatus(e.data.status);
|
|
||||||
setProgress((e.data as any).progress);
|
|
||||||
setDetails((e.data as any).details);
|
|
||||||
setCommands((e.data as any).commands);
|
|
||||||
|
|
||||||
if (e.data.status === 'refresh')
|
|
||||||
{
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
|
|
||||||
Router.navigate({ to: '/game/$source/$id', params: { id, source }, replace: true });
|
|
||||||
} else if (e.data.status === 'error')
|
|
||||||
{
|
|
||||||
const errorMessage = getErrorMessage(e.data.error);
|
|
||||||
if (!errorMessage) return;
|
|
||||||
toast.error(errorMessage);
|
|
||||||
setError(errorMessage);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
{
|
|
||||||
sub.close();
|
|
||||||
ws.current = undefined;
|
|
||||||
};
|
|
||||||
}, [data.id]);
|
|
||||||
|
|
||||||
let progressIcon: JSX.Element | undefined = undefined;
|
|
||||||
switch (status)
|
|
||||||
{
|
|
||||||
case 'download':
|
|
||||||
progressIcon = <Download />;
|
|
||||||
break;
|
|
||||||
case 'queued':
|
|
||||||
progressIcon = <Clock />;
|
|
||||||
break;
|
|
||||||
case 'extract':
|
|
||||||
progressIcon = <PackageOpen />;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showProgress = progress !== null && !!progressIcon;
|
|
||||||
useEffect(() =>
|
|
||||||
{
|
|
||||||
if (showProgress) return;
|
|
||||||
showInstallOptions(false);
|
|
||||||
}, [showProgress]);
|
|
||||||
|
|
||||||
const handlePlay = (cmd?: CommandEntry) =>
|
|
||||||
{
|
|
||||||
if (!cmd) return;
|
|
||||||
if (cmd.emulator === 'EMULATORJS')
|
|
||||||
{
|
|
||||||
const params = new URLSearchParams(cmd.command);
|
|
||||||
Router.navigate({ to: '/embedded/$source/$id', params: { source, id }, search: Object.fromEntries(params.entries()), replace: true });
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
playMut.mutate({ source: data.id.source, id: data.id.id, command_id: cmd.id });
|
|
||||||
Router.navigate({ to: '/launcher/$source/$id', params: { source, id }, replace: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mainButton: any | undefined = undefined;
|
|
||||||
if (status === 'installed')
|
|
||||||
{
|
|
||||||
mainButton = <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)
|
|
||||||
{
|
|
||||||
mainButton = <ActionButton
|
|
||||||
key="error"
|
|
||||||
tooltip={error}
|
|
||||||
tooltip_type="error"
|
|
||||||
type='error'
|
|
||||||
onAction={() =>
|
|
||||||
{
|
|
||||||
if (status === 'missing-emulator')
|
|
||||||
{
|
|
||||||
Router.navigate({ to: '/settings/directories' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
id="mainAction">
|
|
||||||
<TriangleAlert />
|
|
||||||
</ActionButton>;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mainButton = <ActionButton
|
|
||||||
key={status ?? 'unknown'}
|
|
||||||
disabled={installMut.isPending}
|
|
||||||
onAction={() =>
|
|
||||||
{
|
|
||||||
if (status === 'install')
|
|
||||||
{
|
|
||||||
installMut.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tooltip={details ?? status}
|
|
||||||
type='primary'
|
|
||||||
id="mainAction">
|
|
||||||
{status === 'install' ? <Download /> : <span className="loading loading-spinner loading-lg"></span>}
|
|
||||||
</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>
|
|
||||||
{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}
|
|
||||||
</div>
|
|
||||||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
|
||||||
</div>
|
|
||||||
</ActionButton>}
|
|
||||||
{installOptionsDialog}
|
|
||||||
{allCommandDialog}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
...deleteGameMutation(game.id),
|
|
||||||
onSuccess: () =>
|
|
||||||
{
|
|
||||||
location.reload();
|
|
||||||
console.log("Deleted");
|
|
||||||
},
|
|
||||||
onError (error)
|
|
||||||
{
|
|
||||||
toast.error(getErrorMessage(error) ?? "Error While Deleting");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextOptions: DialogEntry[] = [];
|
|
||||||
if (game.local)
|
|
||||||
{
|
|
||||||
contextOptions.push({
|
|
||||||
id: 'delete',
|
|
||||||
action: () =>
|
|
||||||
{
|
|
||||||
deleteMutation.mutate();
|
|
||||||
},
|
|
||||||
icon: <Trash />,
|
|
||||||
content: "Delete",
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTooltipSet = (e: HTMLElement) =>
|
|
||||||
{
|
|
||||||
const dataTooltip = e.getAttribute('data-tooltip');
|
|
||||||
setHoverText(dataTooltip ?? undefined);
|
|
||||||
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent');
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEventListener('focuschanged', (e) =>
|
|
||||||
{
|
|
||||||
if (e.target instanceof HTMLElement)
|
|
||||||
{
|
|
||||||
handleTooltipSet(e.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
}, ref);
|
|
||||||
|
|
||||||
const { isPointer } = useActiveControl();
|
|
||||||
|
|
||||||
const tooltipStyles = {
|
|
||||||
base: 'bg-base-100 text-base-content',
|
|
||||||
accent: 'bg-accent text-accent-content',
|
|
||||||
error: 'bg-error text-error-content'
|
|
||||||
};
|
|
||||||
|
|
||||||
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 />
|
|
||||||
<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 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>}
|
|
||||||
</FocusContext>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{data.icon}
|
|
||||||
{data.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton (data: {
|
|
||||||
id: string,
|
|
||||||
icon?: JSX.Element,
|
|
||||||
children?: any | any[];
|
|
||||||
className?: string;
|
|
||||||
type: "primary" | 'base' | "accent" | 'error';
|
|
||||||
square?: boolean,
|
|
||||||
onFocus?: () => void;
|
|
||||||
tooltip?: string,
|
|
||||||
tooltip_type?: 'accent' | 'error';
|
|
||||||
onAction?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
|
||||||
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-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 (
|
|
||||||
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
|
|
||||||
<button
|
|
||||||
disabled={data.disabled}
|
|
||||||
ref={ref}
|
|
||||||
onClick={data.onAction}
|
|
||||||
data-tooltip={data.tooltip}
|
|
||||||
data-tooltip_type={data.tooltip_type}
|
|
||||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
|
||||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
|
||||||
{data.icon}
|
|
||||||
{data.children}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stats ()
|
|
||||||
{
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
const stats: StatEntry[] = [];
|
const stats: StatEntry[] = [];
|
||||||
if (data.path_fs)
|
if (data.game)
|
||||||
stats.push({ label: "Location", content: data.path_fs, icon: <Folder /> });
|
{
|
||||||
if (data.companies)
|
if (data.game.path_fs)
|
||||||
stats.push({ label: "Companies", content: data.companies });
|
stats.push({ label: "Location", content: data.game.path_fs, icon: <Folder /> });
|
||||||
if (data.genres)
|
if (data.game.companies)
|
||||||
stats.push({ label: 'Genres', content: data.genres });
|
stats.push({ label: "Companies", content: data.game.companies });
|
||||||
if (data.release_date)
|
if (data.game.genres)
|
||||||
stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: <Calendar /> });
|
stats.push({ label: 'Genres', content: data.game.genres });
|
||||||
if (data.emulators)
|
if (data.game.release_date)
|
||||||
stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) });
|
stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: <Calendar /> });
|
||||||
return <StatList elementClassName="bg-base-300" stats={stats} />;
|
if (data.game.emulators)
|
||||||
|
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StatList elementClassName="bg-base-300" stats={stats} id="game-detail-stats" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
|
function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: FrontEndGameTypeDetailed | undefined; })
|
||||||
{
|
{
|
||||||
const [details, setDetails] = useDetailsSection();
|
const [details, setDetails] = useDetailsSection();
|
||||||
const { data: game } = Route.useLoaderData();
|
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: "details-divider",
|
focusKey: "details-divider",
|
||||||
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d),
|
onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d),
|
||||||
|
|
@ -625,7 +200,7 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
|
||||||
stats: { label: "Stats", selected: details === 'stats', icon: <Info /> },
|
stats: { label: "Stats", selected: details === 'stats', icon: <Info /> },
|
||||||
screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: <Image /> },
|
screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: <Image /> },
|
||||||
};
|
};
|
||||||
if (game.achievements)
|
if (data.game?.achievements)
|
||||||
{
|
{
|
||||||
detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: <Trophy /> };
|
detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: <Trophy /> };
|
||||||
}
|
}
|
||||||
|
|
@ -637,18 +212,18 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailsUI ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false);
|
const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false);
|
||||||
const { data } = Route.useLoaderData();
|
const { source, id } = Route.useParams();
|
||||||
|
const { data } = useQuery(gameQuery(source, id));
|
||||||
const { focus } = Route.useSearch();
|
const { focus } = Route.useSearch();
|
||||||
const [, setUpdate] = useState(0);
|
const [, setUpdate] = useState(0);
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
|
||||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
||||||
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible });
|
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||||
const { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
|
|
@ -666,7 +241,7 @@ export default function GameDetailsUI ()
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||||
const recommendedEmulators = data.emulators?.filter(e => e.store_exists);
|
const recommendedEmulators = data?.emulators?.filter(e => e.validSource);
|
||||||
|
|
||||||
const { ref: intersct } = useIntersectionObserver({
|
const { ref: intersct } = useIntersectionObserver({
|
||||||
onChange: (isIntersecting, entry) =>
|
onChange: (isIntersecting, entry) =>
|
||||||
|
|
@ -686,13 +261,14 @@ export default function GameDetailsUI ()
|
||||||
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||||
<HeaderUI />
|
<HeaderUI />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||||
<Details mainAreaRef={mainAreaRef} />
|
<Details game={data} id={id} source={source} />
|
||||||
</div>
|
</div>
|
||||||
<MoreDetails />
|
<MoreDetails game={data} />
|
||||||
<div className="relative bg-base-300">
|
<div className="relative">
|
||||||
|
<div className="bg-dots"></div>
|
||||||
{!!recommendedEmulators && recommendedEmulators.length > 0 && <EmulatorsSection
|
{!!recommendedEmulators && recommendedEmulators.length > 0 && <EmulatorsSection
|
||||||
id={`${data.id.id}-recommended`}
|
id={`${data?.id.id}-recommended`}
|
||||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||||
<h2 className="font-bold uppercase tracking-widest">
|
<h2 className="font-bold uppercase tracking-widest">
|
||||||
Related Emulators
|
Related Emulators
|
||||||
|
|
@ -703,6 +279,7 @@ export default function GameDetailsUI ()
|
||||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
Router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||||
}}
|
}}
|
||||||
emulators={recommendedEmulators} />}
|
emulators={recommendedEmulators} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-100">
|
<div className="bg-base-100">
|
||||||
<div className="px-6 py-3">
|
<div className="px-6 py-3">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { JSX, Suspense, useContext, useEffect, useState } from "react";
|
import { JSX, Suspense, useContext, useState } from "react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
|
|
@ -14,7 +14,6 @@ import
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
useNavigate,
|
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import
|
import
|
||||||
|
|
@ -25,7 +24,7 @@ import
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEventListener } from "usehooks-ts";
|
import { useEventListener } from "usehooks-ts";
|
||||||
import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
|
import { HeaderAccounts, HeaderButton, HeaderStatusBar } from "../components/Header";
|
||||||
import { FilterUI } from "../components/Filters";
|
import { FilterUI } from "../components/Filters";
|
||||||
import { AnimatedBackground } from "../components/AnimatedBackground";
|
import { AnimatedBackground } from "../components/AnimatedBackground";
|
||||||
import { GameList } from "../components/GameList";
|
import { GameList } from "../components/GameList";
|
||||||
|
|
@ -43,7 +42,6 @@ import CollectionList from "../components/CollectionList";
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import { mobileCheck, useDragScroll } from "../scripts/utils";
|
import { mobileCheck, useDragScroll } from "../scripts/utils";
|
||||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||||
import { FrontEndId } from "@/shared/constants";
|
|
||||||
import Carousel from "../components/Carousel";
|
import Carousel from "../components/Carousel";
|
||||||
import { closeMutation } from "@queries/system";
|
import { closeMutation } from "@queries/system";
|
||||||
|
|
||||||
|
|
@ -301,10 +299,14 @@ export default function ConsoleHomeUI ()
|
||||||
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
|
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
|
||||||
|
|
||||||
const { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
const headerButtons = [];
|
const headerButtons: HeaderButton[] = [];
|
||||||
if (mobileCheck())
|
if (mobileCheck())
|
||||||
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
||||||
headerButtons.push({ id: "search", icon: <Search /> }, { id: "power-button", icon: <Power />, external: true, action: () => close.mutate() });
|
headerButtons.push(
|
||||||
|
{ id: "search-header-button", icon: <Search /> },
|
||||||
|
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate() },
|
||||||
|
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => Router.navigate({ to: "/settings/accounts" }) }
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
|
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
|
||||||
import DotsLoading from '../components/backgrounds/dots';
|
import DotsLoading from '../components/backgrounds/dots';
|
||||||
import { Router } from '..';
|
import { Router } from '..';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
@ -9,6 +8,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
|
||||||
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import Shortcuts from '../components/Shortcuts';
|
import Shortcuts from '../components/Shortcuts';
|
||||||
import { gameQuery } from '@queries/romm';
|
import { gameQuery } from '@queries/romm';
|
||||||
|
import { rommApi } from '../scripts/clientApi';
|
||||||
|
|
||||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -30,30 +30,22 @@ function RouteComponent ()
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${source}/${id}`);
|
if (!data) return;
|
||||||
|
const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
|
||||||
|
|
||||||
es.onmessage = ({ data }) =>
|
sub.subscribe((e) =>
|
||||||
{
|
{
|
||||||
const stats = JSON.parse(data) as GameInstallProgress;
|
if (e.data.status !== 'playing')
|
||||||
if (stats.status !== 'playing')
|
|
||||||
{
|
{
|
||||||
HandleGoBack();
|
HandleGoBack();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
es.addEventListener('refresh', () =>
|
|
||||||
{
|
|
||||||
HandleGoBack();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
es.onerror = () =>
|
return () =>
|
||||||
{
|
{
|
||||||
HandleGoBack();
|
sub.close();
|
||||||
};
|
};
|
||||||
|
}, [data?.id]);
|
||||||
return () => es.close();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
|
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
|
||||||
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
||||||
import { Block, createFileRoute } from '@tanstack/react-router';
|
import { Block, createFileRoute } from '@tanstack/react-router';
|
||||||
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
|
||||||
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { DownloadsDrive } from '@/shared/constants';
|
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
|
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
|
|
@ -13,6 +12,7 @@ import { Button } from '@/mainview/components/options/Button';
|
||||||
import { systemApi } from '@/mainview/scripts/clientApi';
|
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||||
import useActiveControl from '@/mainview/scripts/gamepads';
|
import useActiveControl from '@/mainview/scripts/gamepads';
|
||||||
import { changeDownloadsMutation } from '@queries/settings';
|
import { changeDownloadsMutation } from '@queries/settings';
|
||||||
|
import { downloadDrivesQuery } from '@/mainview/scripts/queries/system';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/directories')({
|
export const Route = createFileRoute('/settings/directories')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -79,8 +79,8 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMoving = useIsMutating(queries.settings.changeDownloadsMutation);
|
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||||
const { data: drives, refetch } = useQuery({ ...queries.system.downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
|
const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
|
||||||
|
|
||||||
return <FocusContext value={focusKey}>
|
return <FocusContext value={focusKey}>
|
||||||
<Block shouldBlockFn={() => isMoving > 0} withResolver={false} />
|
<Block shouldBlockFn={() => isMoving > 0} withResolver={false} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { OptionSpace } from '../../components/options/OptionSpace';
|
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||||
import { OptionInput } from '../../components/options/OptionInput';
|
import { OptionInput } from '../../components/options/OptionInput';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button } from '../../components/options/Button';
|
import { Button } from '../../components/options/Button';
|
||||||
import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||||
|
|
@ -15,6 +15,10 @@ import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
import FilePicker from '@/mainview/components/FilePicker';
|
import FilePicker from '@/mainview/components/FilePicker';
|
||||||
import { dirname } from 'pathe';
|
import { dirname } from 'pathe';
|
||||||
import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings';
|
import { autoEmulatorsQuery, customEmulatorAddMutation, customEmulatorDeleteMutation, customEmulatorRemoveValueQuery, customEmulatorsQuery, setCustomEmulatorMutation } from '@queries/settings';
|
||||||
|
import Carousel from '@/mainview/components/Carousel';
|
||||||
|
import { FOCUS_KEYS } from '@/mainview/scripts/types';
|
||||||
|
import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils';
|
||||||
|
import { SettingsOption } from '@/mainview/components/options/SettingsOption';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/emulators')({
|
export const Route = createFileRoute('/settings/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -99,6 +103,7 @@ function EmulatorPath (data: { id: string; })
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||||
const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id));
|
const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id));
|
||||||
|
useEffect(() => { setLocalValue(remoteValue); }, [remoteValue]);
|
||||||
const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) =>
|
const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) =>
|
||||||
{
|
{
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
|
|
@ -128,7 +133,7 @@ function EmulatorPath (data: { id: string; })
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionSpace id={`${data.id}-space`} label={
|
<OptionSpace id={FOCUS_KEYS.EMULATOR_CUSTOM_PATH(data.id)} label={
|
||||||
focus => <>
|
focus => <>
|
||||||
<p className='font-semibold'>{data.id}</p>
|
<p className='font-semibold'>{data.id}</p>
|
||||||
<small className='opacity-40'>{emulators[data.id]}</small>
|
<small className='opacity-40'>{emulators[data.id]}</small>
|
||||||
|
|
@ -140,7 +145,6 @@ function EmulatorPath (data: { id: string; })
|
||||||
type="text"
|
type="text"
|
||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
defaultValue={remoteValue}
|
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
{
|
{
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
|
|
@ -187,22 +191,22 @@ function EmulatorBadge (data: {
|
||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
pathCover?: string;
|
pathCover?: string;
|
||||||
addOverride: (emulator: string) => void;
|
addOverride: (emulator: string) => void;
|
||||||
})
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
const { focusKey, ref, focused } = useFocusable({
|
const { focusKey, ref, focused } = useFocusable({
|
||||||
focusKey: `badge-${data.emulator}`, onFocus: () =>
|
focusKey: FOCUS_KEYS.EMULATOR_CARD(data.emulator),
|
||||||
{
|
onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); }
|
||||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{
|
useShortcuts(focusKey, () => [{
|
||||||
label: 'Add Override', button: GamePadButtonCode.A, action: () =>
|
label: 'Add Override',
|
||||||
|
button: GamePadButtonCode.A,
|
||||||
|
action: () =>
|
||||||
data.addOverride(data.emulator)
|
data.addOverride(data.emulator)
|
||||||
}], [data.addOverride]);
|
}], [data.addOverride]);
|
||||||
|
|
||||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
return <div ref={ref} className={classNames("tooltip tooltip-primary tooltip-right", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||||
<div ref={ref} className={
|
<div className={
|
||||||
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
|
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
|
||||||
classNames({
|
classNames({
|
||||||
"bg-base-200": !data.path,
|
"bg-base-200": !data.path,
|
||||||
|
|
@ -221,15 +225,38 @@ function EmulatorBadge (data: {
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; } & FocusParams)
|
||||||
{
|
{
|
||||||
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
|
const { data: autoEmulators } = useQuery({
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
|
...autoEmulatorsQuery,
|
||||||
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
select (data)
|
||||||
|
{
|
||||||
|
return data.toSorted((a, b) =>
|
||||||
|
{
|
||||||
|
const sourceCompare = (b.validSource ? 1 : 0) - (a.validSource ? 1 : 0);
|
||||||
|
if (sourceCompare !== 0)
|
||||||
|
{
|
||||||
|
return sourceCompare;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return b.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { ref, focusKey } = useFocusable({
|
||||||
|
focusKey: `emulator-badges`,
|
||||||
|
focusable: !!autoEmulators && autoEmulators.length > 0,
|
||||||
|
onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); }
|
||||||
|
});
|
||||||
|
useDragScroll(ref);
|
||||||
|
return <Carousel scrollRef={ref} className='grid grid-flow-col overflow-x-scroll auto-cols-[16rem] grid-rows-[repeat(3,4rem)] gap-2 justify-center-safe py-4 no-scrollbar'>
|
||||||
|
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
{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} />)}
|
{autoEmulators?.map(e => <EmulatorBadge onFocus={(k, n, d) => scrollIntoNearestParent(n)} key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.validSource?.binPath} exists={!!e.validSource} emulator={e.name} />)}
|
||||||
|
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</Carousel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
|
|
@ -242,11 +269,19 @@ function RouteComponent ()
|
||||||
|
|
||||||
const { data: customEmulators } = useQuery(customEmulatorsQuery);
|
const { data: customEmulators } = useQuery(customEmulatorsQuery);
|
||||||
|
|
||||||
const addOverrideMutation = useMutation(customEmulatorAddMutation);
|
const addOverrideMutation = useMutation({
|
||||||
|
...customEmulatorAddMutation, async onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
await context.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||||
|
setFocus(FOCUS_KEYS.EMULATOR_CUSTOM_PATH(variables));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return <FocusContext value={focusKey}>
|
return <FocusContext value={focusKey}>
|
||||||
<ul ref={ref} className="list rounded-box gap-2">
|
<ul ref={ref} className="list rounded-box gap-2">
|
||||||
<EmulatorBadges addOverride={addOverrideMutation.mutate} />
|
<EmulatorBadges addOverride={addOverrideMutation.mutate} onFocus={scrollIntoViewHandler({ block: 'center' })} />
|
||||||
|
<div className="divider text-base-content/40">Preferences</div>
|
||||||
|
<SettingsOption label="Launch In Fullscreen" id="launchInFullscreen" type="checkbox" />
|
||||||
<div className="divider text-base-content/40">Overrides</div>
|
<div className="divider text-base-content/40">Overrides</div>
|
||||||
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />
|
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />
|
||||||
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
||||||
|
|
|
||||||
55
src/mainview/routes/settings/plugins.tsx
Normal file
55
src/mainview/routes/settings/plugins.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
|
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||||
|
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||||
|
import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Puzzle, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/settings/plugins')({
|
||||||
|
component: RouteComponent,
|
||||||
|
loader (ctx)
|
||||||
|
{
|
||||||
|
ctx.context.queryClient.prefetchQuery(getAllPluginsQuery);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Plugin (data: {
|
||||||
|
plugin: FrontendPlugin,
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return <OptionSpace label={<div className='flex gap-4 items-center'>
|
||||||
|
<div className='flex bg-accent text-accent-content rounded-full size-12 p-2 items-center justify-center'>
|
||||||
|
{data.plugin.icon ? <img src={data.plugin.icon}></img> : <Puzzle />}
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<div>{data.plugin.displayName}</div>
|
||||||
|
<div className='text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
||||||
|
</div>
|
||||||
|
</div>} className='flex p-4 bg-base-200 rounded-3xl'>
|
||||||
|
<OptionInput onChange={data.setEnabled} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />
|
||||||
|
<Button id={`${data.plugin.name}-details`} ><Search /> Details</Button>
|
||||||
|
</OptionSpace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent ()
|
||||||
|
{
|
||||||
|
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
||||||
|
const pluginMutation = useMutation({
|
||||||
|
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
refetchPlugins();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{!!plugins && Object.entries(Object.groupBy(plugins, p => p.source)).map(([source, plugins]) =>
|
||||||
|
{
|
||||||
|
return <>
|
||||||
|
<div className="divider">{source === 'builtin' ? "Built In" : "Store"}</div>
|
||||||
|
{plugins.map(p => <Plugin key={p.name} plugin={p} setEnabled={(v) => pluginMutation.mutate({ id: p.name, enabled: v })} />)}
|
||||||
|
</>;
|
||||||
|
})}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import
|
||||||
Info,
|
Info,
|
||||||
Joystick,
|
Joystick,
|
||||||
MonitorCog,
|
MonitorCog,
|
||||||
|
Puzzle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { JSX, useEffect } from "react";
|
import { JSX, useEffect } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
@ -141,6 +142,12 @@ function SettingsMenu (data: {})
|
||||||
label="Directories"
|
label="Directories"
|
||||||
icon={<HardDrive />}
|
icon={<HardDrive />}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
focusSelect
|
||||||
|
route="/settings/plugins"
|
||||||
|
label="Plugins"
|
||||||
|
icon={<Puzzle />}
|
||||||
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
focusSelect
|
focusSelect
|
||||||
route="/settings/about"
|
route="/settings/about"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
useFocusable,
|
useFocusable,
|
||||||
|
|
@ -11,9 +11,9 @@ import Shortcuts from "@/mainview/components/Shortcuts";
|
||||||
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
||||||
import { systemApi } from "@/mainview/scripts/clientApi";
|
import { systemApi } from "@/mainview/scripts/clientApi";
|
||||||
import { Button } from "@/mainview/components/options/Button";
|
import { Button } from "@/mainview/components/options/Button";
|
||||||
import { ChevronDown, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert } from "lucide-react";
|
import { ChevronDown, Cpu, Download, Gamepad2, Info, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
|
||||||
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
|
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
|
||||||
import { FrontEndEmulatorDetailed, RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import Screenshots from "@/mainview/components/Screenshots";
|
import Screenshots from "@/mainview/components/Screenshots";
|
||||||
import { StickyHeaderUI } from "@/mainview/components/Header";
|
import { StickyHeaderUI } from "@/mainview/components/Header";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -24,8 +24,9 @@ import { getErrorMessage } from "react-error-boundary";
|
||||||
import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard";
|
import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard";
|
||||||
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
import StatList, { StatEntry } from "@/mainview/components/StatList";
|
||||||
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||||
import { installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store";
|
import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store";
|
||||||
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
|
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
|
||||||
|
import FocusTooltip from "@/mainview/components/FocusTooltip";
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/details/emulator/$id')({
|
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -42,7 +43,7 @@ function HomePageLink (data: { homepage?: string; })
|
||||||
const { ref } = useFocusable({ focusKey: 'homepage-link' });
|
const { ref } = useFocusable({ focusKey: 'homepage-link' });
|
||||||
return <a
|
return <a
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1"
|
className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1 not-mobile:shadow-2xl"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
{
|
{
|
||||||
if (data.homepage) systemApi.api.system.open.post({ url: data.homepage });
|
if (data.homepage) systemApi.api.system.open.post({ url: data.homepage });
|
||||||
|
|
@ -58,10 +59,44 @@ function TitleArea (data: {
|
||||||
{
|
{
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
...storeEmulatorDeleteMutation, onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(variables)),
|
...storeEmulatorDeleteMutation,
|
||||||
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
context.client.refetchQueries(storeEmulatorDetailsQuery(variables));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? ''));
|
||||||
|
const deleteBios = useMutation({
|
||||||
|
...deleteBiosMutation,
|
||||||
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
context.client.refetchQueries(storeEmulatorDetailsQuery(variables));
|
||||||
|
toast.success("BIOS Deleted", { icon: <Trash2 /> });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const installProgressRef = useRef<HTMLProgressElement>(null);
|
const installProgressRef = useRef<HTMLProgressElement>(null);
|
||||||
const { data: installJob, status: installStatus } = useJobStatus('download-emulator', {
|
const { data: biosInstallJob, state: biosDownloadState } = useJobStatus('bios-download-job', {
|
||||||
|
query: { id: data.emulator?.name },
|
||||||
|
onError (error)
|
||||||
|
{
|
||||||
|
console.log(error);
|
||||||
|
toast.error(getErrorMessage(error) ?? "Error During Bios Download");
|
||||||
|
},
|
||||||
|
onProgress (process)
|
||||||
|
{
|
||||||
|
if (installProgressRef.current)
|
||||||
|
installProgressRef.current.value = process;
|
||||||
|
},
|
||||||
|
onCompleted (data)
|
||||||
|
{
|
||||||
|
toast.success("BIOS Downloaded", { icon: <Download /> });
|
||||||
|
},
|
||||||
|
onEnded (data)
|
||||||
|
{
|
||||||
|
queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: installJob, state: installState } = useJobStatus('download-emulator', {
|
||||||
onError (error)
|
onError (error)
|
||||||
{
|
{
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|
@ -80,12 +115,13 @@ function TitleArea (data: {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isInstalling = !!installJob;
|
const isInstalling = !!installJob || !!biosInstallJob;
|
||||||
|
|
||||||
const options: DialogEntry[] = [];
|
const options: DialogEntry[] = [];
|
||||||
|
const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists);
|
||||||
if (data.emulator)
|
if (data.emulator)
|
||||||
{
|
{
|
||||||
if (!isInstalling && !data.emulator?.validSource)
|
if (!isInstalling && !installedFromStore)
|
||||||
{
|
{
|
||||||
options.push(...data.emulator.downloads.map(d =>
|
options.push(...data.emulator.downloads.map(d =>
|
||||||
{
|
{
|
||||||
|
|
@ -101,7 +137,7 @@ function TitleArea (data: {
|
||||||
};
|
};
|
||||||
return entry;
|
return entry;
|
||||||
}));
|
}));
|
||||||
} else if (data.emulator.sources.find(s => s.type === 'store' && s.exists))
|
} else if (installedFromStore)
|
||||||
{
|
{
|
||||||
options.push({
|
options.push({
|
||||||
content: "Delete",
|
content: "Delete",
|
||||||
|
|
@ -114,12 +150,43 @@ function TitleArea (data: {
|
||||||
},
|
},
|
||||||
id: "delete"
|
id: "delete"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!data.emulator.bios || data.emulator.bios.length <= 0)
|
||||||
|
{
|
||||||
|
options.push({
|
||||||
|
content: "Download BIOS",
|
||||||
|
type: "primary",
|
||||||
|
icon: <Download />,
|
||||||
|
action (ctx)
|
||||||
|
{
|
||||||
|
downloadBios.mutate();
|
||||||
|
ctx.close();
|
||||||
|
},
|
||||||
|
id: "download-bios"
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
options.push({
|
||||||
|
content: "Delete BIOS",
|
||||||
|
type: "error",
|
||||||
|
icon: <Trash2 />,
|
||||||
|
action (ctx)
|
||||||
|
{
|
||||||
|
if (!data.emulator) return;
|
||||||
|
deleteBios.mutate(data.emulator.name);
|
||||||
|
ctx.close();
|
||||||
|
},
|
||||||
|
id: "download-bios"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||||
focusKey: 'title-area',
|
focusKey: 'title-area',
|
||||||
preferredChildFocusKey: "install-btn",
|
preferredChildFocusKey: "install-btn",
|
||||||
|
trackChildren: true,
|
||||||
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
|
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,7 +198,16 @@ function TitleArea (data: {
|
||||||
}
|
}
|
||||||
else if (isInstalling)
|
else if (isInstalling)
|
||||||
{
|
{
|
||||||
installButtonContent = <><span className="loading loading-spinner loading-lg"></span>{installStatus}</>;
|
const status: any = {
|
||||||
|
bios: {
|
||||||
|
download: "Downloading BIOS"
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
download: "Downloading",
|
||||||
|
extract: "Extracting"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
installButtonContent = <><span className="loading loading-spinner loading-lg"></span>{installState ? status.install[installState] : biosDownloadState ? status.bios[biosDownloadState] : undefined}</>;
|
||||||
} else if (data.emulator.validSource)
|
} else if (data.emulator.validSource)
|
||||||
{
|
{
|
||||||
installButtonContent = <><Settings /> Options</>;
|
installButtonContent = <><Settings /> Options</>;
|
||||||
|
|
@ -155,25 +231,37 @@ function TitleArea (data: {
|
||||||
|
|
||||||
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
|
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
{data.emulator ? <img className="size-32" src={data.emulator.logo}></img> : <div className="skeleton h-32 w-32" />}
|
{data.emulator ? <img className="size-32 rounded-full shadow-lg bg-base-200 ring-7 ring-base-200" 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">
|
<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>
|
<h1 className="text-4xl font-semibold text-shadow-md">{data.emulator?.name ?? <div className="skeleton h-10 w-84" />}</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{data.emulator?.systems.map(({ id, name, icon }) =>
|
{data.emulator?.systems.map(({ id, name, icon }) =>
|
||||||
{
|
{
|
||||||
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
|
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
|
||||||
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
|
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
|
||||||
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
|
<p className="text-nowrap text-ellipsis overflow-hidden dark:text-shadow-lg">{name}</p>
|
||||||
</div>;
|
</div>;
|
||||||
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
|
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex pt-2 gap-1">
|
<div className="flex pt-2 gap-1">
|
||||||
<HomePageLink homepage={data.emulator?.homepage} />
|
<HomePageLink homepage={data.emulator?.homepage} />
|
||||||
|
<div className="divider divider-horizontal m-0"></div>
|
||||||
|
{!!data.emulator?.bios?.[0] && <div className="tooltip" data-tip="Has BIOS">
|
||||||
|
<div className="flex items-center justify-center bg-base-200 p-2 rounded-full"><Cpu className="size-5" /></div>
|
||||||
|
</div>}
|
||||||
|
{data.emulator && !!data.emulator.integration && data.emulator.validSource?.type === 'store' && <div className="tooltip" data-tip="Has Integration">
|
||||||
|
<div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4">
|
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
|
||||||
<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} >
|
<FocusTooltip visible={hasFocusedChild} parentRef={ref} />
|
||||||
|
{(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS">
|
||||||
|
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => setOpen(true, 'bios-warning-bt')}><TriangleAlert /></Button>
|
||||||
|
</div>}
|
||||||
|
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2 light:ring-offset-7 light:ring-offset-base-100 light:focused:ring-offset-0 shadow-lg" onAction={handleOptionsOpen} >
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|
||||||
{installButtonContent}
|
{installButtonContent}
|
||||||
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
||||||
<ChevronDown />
|
<ChevronDown />
|
||||||
|
|
@ -189,11 +277,11 @@ function TitleArea (data: {
|
||||||
function Description (data: { emulator?: FrontEndEmulatorDetailed; })
|
function Description (data: { emulator?: FrontEndEmulatorDetailed; })
|
||||||
{
|
{
|
||||||
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
|
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
|
||||||
<p>{data.emulator?.description ?? <div className="flex flex-col gap-4 w-full">
|
<div>{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-[40%]"></div>
|
||||||
<div className="skeleton h-4 w-[80%]"></div>
|
<div className="skeleton h-4 w-[80%]"></div>
|
||||||
<div className="skeleton h-4 w-full"></div>
|
<div className="skeleton h-4 w-full"></div>
|
||||||
</div>}</p>
|
</div>}</div>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,6 +324,14 @@ export function RouteComponent ()
|
||||||
stats.push({ label: "Tags", content: emulator.keywords });
|
stats.push({ label: "Tags", content: emulator.keywords });
|
||||||
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
|
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 }]));
|
stats.push(...emulator.sources.flatMap(s => [{ label: "Source", content: s.type, icon: emulatorStatusIcons[s.type] }, { label: "Location", content: s.binPath }]));
|
||||||
|
if (emulator.bios)
|
||||||
|
stats.push({
|
||||||
|
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
|
||||||
|
});
|
||||||
|
if (emulator.integration)
|
||||||
|
{
|
||||||
|
stats.push({ label: "Integration", content: `${emulator.integration.name} (${emulator.integration.version})` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -248,6 +344,7 @@ export function RouteComponent ()
|
||||||
|
|
||||||
<div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div>
|
<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 className='mobile:hidden left-0 top-0 absolute bg-noise'></div>
|
||||||
|
<div className='mobile:hidden left-0 top-0 absolute bg-dots'></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col bg-base-100 gap-4 pt-4 h-[50vh] min-h-128 grow text-lg">
|
<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' })} />}
|
{isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && <Screenshots className="grow bg-base-200" screenshots={emulator?.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />}
|
||||||
|
|
@ -258,6 +355,7 @@ export function RouteComponent ()
|
||||||
<div className="divider"> <Info className="size-12" /> Stats</div>
|
<div className="divider"> <Info className="size-12" /> Stats</div>
|
||||||
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
|
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
|
||||||
{recommendedEmulators && <div className="relative bg-base-200">
|
{recommendedEmulators && <div className="relative bg-base-200">
|
||||||
|
<div className="bg-dots z-0"></div>
|
||||||
<EmulatorsSection
|
<EmulatorsSection
|
||||||
id={`${id}-recommended`}
|
id={`${id}-recommended`}
|
||||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { MissingEmulatorsSection } from "../../../components/store/MissingEmulat
|
||||||
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
||||||
import { GamesSection } from "../../../components/store/GamesSection";
|
import { GamesSection } from "../../../components/store/GamesSection";
|
||||||
import { StatsSection } from "../../../components/store/StatsSection";
|
import { StatsSection } from "../../../components/store/StatsSection";
|
||||||
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
import { RPC_URL } from '@/shared/constants';
|
||||||
import { useContext, useEffect, useRef, useState } from 'react';
|
import { useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
|
|
@ -53,18 +53,18 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
|
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
|
||||||
previewUrl?.searchParams.set('blur', '16');
|
previewUrl?.searchParams.set('blur', '16');
|
||||||
|
|
||||||
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable p-4 mt-4 gap-4'>
|
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable md:px-12 p-4 mt-4 gap-6'>
|
||||||
|
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-xl w-full">
|
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-lg w-full ring-6 ring-base-200 border-6 border-base-200">
|
||||||
<div className='flex relative h-full overflow-hidden'>
|
<div className='flex relative h-full overflow-hidden'>
|
||||||
<div className='absolute w-full h-full z-0 bg-base-200'>
|
<div className='absolute w-full h-full z-0 bg-base-200'>
|
||||||
<img key={selectedGame}
|
<img key={selectedGame}
|
||||||
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 z-0 mask-l-from-0'
|
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-100 z-0 mask-l-from-0'
|
||||||
src={previewUrl?.href}
|
src={previewUrl?.href}
|
||||||
onLoad={(e) =>
|
onLoad={(e) =>
|
||||||
{
|
{
|
||||||
e.currentTarget.classList.toggle('opacity-0', false);
|
e.currentTarget.dataset.loaded = "true";
|
||||||
e.currentTarget.classList.toggle('scale-110', false);
|
e.currentTarget.classList.toggle('scale-110', false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -140,9 +140,9 @@ export function RouteComponent ()
|
||||||
|
|
||||||
<div className="px-6 py-3">
|
<div className="px-6 py-3">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-sm" />
|
||||||
<Gamepad2 className="text-accent" />
|
<Gamepad2 className="text-accent text-shadow-sm" />
|
||||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
<h2 className="font-bold uppercase tracking-widest text-accent grow text-shadow-sm">
|
||||||
Featured Games
|
Featured Games
|
||||||
</h2>
|
</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 className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ import { HeaderUI } from '@/mainview/components/Header';
|
||||||
import Shortcuts from '@/mainview/components/Shortcuts';
|
import Shortcuts from '@/mainview/components/Shortcuts';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
|
||||||
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
|
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
|
||||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { useMatchRoute } from '@tanstack/react-router';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
|
|
@ -111,11 +110,6 @@ function RouteComponent ()
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToSettings = () =>
|
|
||||||
{
|
|
||||||
Router.navigate({ to: '/settings' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMobile = mobileCheck();
|
const isMobile = mobileCheck();
|
||||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||||
|
|
||||||
|
|
@ -125,7 +119,7 @@ function RouteComponent ()
|
||||||
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
|
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<div ref={sentinelRef} className="h-0" />
|
||||||
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
|
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
|
||||||
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
<HeaderUI />
|
||||||
</div>
|
</div>
|
||||||
<TopArea filters={filters} />
|
<TopArea filters={filters} />
|
||||||
<StoreOutlet />
|
<StoreOutlet />
|
||||||
|
|
@ -135,6 +129,7 @@ function RouteComponent ()
|
||||||
{!isMobile && <>
|
{!isMobile && <>
|
||||||
<div className='bg-gradient'></div>
|
<div className='bg-gradient'></div>
|
||||||
<div className='bg-noise'></div>
|
<div className='bg-noise'></div>
|
||||||
|
<div className='bg-dots'></div>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Treaty, treaty } from "@elysiajs/eden";
|
import { Treaty, treaty } from "@elysiajs/eden";
|
||||||
import { JobsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
|
import { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { RPC_URL } from "../../shared/constants";
|
||||||
|
|
||||||
const options: Treaty.Config = {
|
const options: Treaty.Config = {
|
||||||
|
|
@ -13,4 +13,5 @@ export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), options);
|
||||||
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), options);
|
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), options);
|
||||||
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), options);
|
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), options);
|
||||||
export const storeApi = treaty<StoreAPIType>(RPC_URL(__HOST__), options);
|
export const storeApi = treaty<StoreAPIType>(RPC_URL(__HOST__), options);
|
||||||
export const jobsApi = treaty<JobsAPIType>(RPC_URL(__HOST__), options);
|
export const jobsApi = treaty<JobsAPIType>(RPC_URL(__HOST__), options);
|
||||||
|
export const pluginsApi = treaty<PluginsAPIType>(RPC_URL(__HOST__), options);
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Drive } from "@/shared/constants";
|
|
||||||
import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
|
|
||||||
21
src/mainview/scripts/queries/plugins.ts
Normal file
21
src/mainview/scripts/queries/plugins.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
|
import { pluginsApi } from "../clientApi";
|
||||||
|
|
||||||
|
export const getAllPluginsQuery = queryOptions({
|
||||||
|
queryKey: ['plugins', 'all'], queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await pluginsApi.plugins.get();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enablePluginMutation = mutationOptions({
|
||||||
|
mutationKey: ['plugin', 'enable'],
|
||||||
|
mutationFn: async (vars: { id: string, enabled: boolean; }) =>
|
||||||
|
{
|
||||||
|
const { error } = await pluginsApi.plugins({ id: vars.id }).post({ enabled: vars.enabled });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { DefaultRommStaleTime, FrontEndId, GameListFilterType, RommLoginDataSchema, RPC_URL } from "@/shared/constants";
|
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
|
||||||
import { rommApi, settingsApi } from "../clientApi";
|
import { rommApi, settingsApi } from "../clientApi";
|
||||||
import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
@ -45,7 +45,7 @@ export const rommLoginMutation = mutationOptions({
|
||||||
});
|
});
|
||||||
export const rommUserQuery = () => queryOptions({
|
export const rommUserQuery = () => queryOptions({
|
||||||
...getCurrentUserApiUsersMeGetOptions(),
|
...getCurrentUserApiUsersMeGetOptions(),
|
||||||
queryKey: ['romm', 'auth', "login"],
|
queryKey: ['romm', 'auth', "login"] as any,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 0
|
retry: 0
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,7 @@ export const customEmulatorAddMutation = mutationOptions({
|
||||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
},
|
}
|
||||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
|
||||||
});
|
});
|
||||||
export const customEmulatorDeleteMutation = (id: string) => mutationOptions({
|
export const customEmulatorDeleteMutation = (id: string) => mutationOptions({
|
||||||
mutationKey: ["emulator", id, 'delete'],
|
mutationKey: ["emulator", id, 'delete'],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
|
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
import { rommApi, storeApi } from "../clientApi";
|
import { rommApi, storeApi } from "../clientApi";
|
||||||
import { FrontEndGameType } from "@/shared/constants";
|
|
||||||
|
|
||||||
|
|
||||||
export const storeEmulatorsQuery = queryOptions({
|
export const storeEmulatorsQuery = queryOptions({
|
||||||
|
|
@ -71,4 +70,19 @@ export const installEmulatorMutation = (id: string) => mutationOptions({
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
export const downloadBiosMutation = (id: string) => mutationOptions({
|
||||||
|
mutationKey: ["download", 'bios', id],
|
||||||
|
mutationFn: async () =>
|
||||||
|
{
|
||||||
|
const { error } = await storeApi.api.store.download.bios({ id }).post();
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const deleteBiosMutation = mutationOptions({
|
||||||
|
mutationKey: ["delete", "bios"], mutationFn: async (id: string) =>
|
||||||
|
{
|
||||||
|
const { error } = await storeApi.api.store.bios({ id }).delete();
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { FrontEndId } from "@/shared/constants";
|
|
||||||
|
|
||||||
export const FOCUS_KEYS = {
|
export const FOCUS_KEYS = {
|
||||||
NAV_CATEGORIES: "NAV_CATEGORIES",
|
NAV_CATEGORIES: "NAV_CATEGORIES",
|
||||||
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
||||||
MISSING_SECTION: "MISSING_SECTION",
|
MISSING_SECTION: "MISSING_SECTION",
|
||||||
MISSING_CARD: (id: string) => `MISSING_${id}`,
|
MISSING_CARD: (id: string) => `MISSING_${id}`,
|
||||||
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
||||||
|
EMULATOR_CUSTOM_PATH: (id: string) => `EMULATOR_CUSTOM_PATH_${id}`,
|
||||||
|
CONTEXT_DIALOG_OPTION: (contextId: string, id: string) => `${contextId}_LIST_OPTION${id}`,
|
||||||
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
||||||
GAME_SECTION: "GAME_SECTION",
|
GAME_SECTION: "GAME_SECTION",
|
||||||
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
|
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
||||||
import { doesFocusableExist, FocusableComponentLayout, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { RefObject, useEffect, useRef, useState } from "react";
|
import { RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
import { jobsApi } from "./clientApi";
|
import { jobsApi } from "./clientApi";
|
||||||
import { JobsAPIType } from "@/bun/api/rpc";
|
import { JobsAPIType } from "@/bun/api/rpc";
|
||||||
import { Router } from "..";
|
import { Router } from "..";
|
||||||
import data from "@emulators";
|
import Elysia from "elysia";
|
||||||
|
import { Prettify } from "elysia/types";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
||||||
{
|
{
|
||||||
|
|
@ -70,10 +72,10 @@ export function mobileCheck ()
|
||||||
return check;
|
return check;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useLocalSetting (key: keyof LocalSettingsType)
|
export function useLocalSetting<TKey extends keyof LocalSettingsType> (key: TKey)
|
||||||
{
|
{
|
||||||
const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) });
|
const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) });
|
||||||
return localValue;
|
return localValue as LocalSettingsType[TKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAsyncGenerator<T> (
|
export function useAsyncGenerator<T> (
|
||||||
|
|
@ -268,8 +270,10 @@ type JobResponse<JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> =
|
||||||
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
|
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
|
||||||
id: JOB,
|
id: JOB,
|
||||||
init?: {
|
init?: {
|
||||||
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress", 'data'>) => void,
|
query?: Record<string, any>,
|
||||||
|
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void,
|
||||||
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||||
|
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -279,12 +283,12 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
|
|
||||||
const ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
|
const ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
|
||||||
const [data, setData] = useState<DataPayload>();
|
const [data, setData] = useState<DataPayload>();
|
||||||
const [status, setStatus] = useState<string>();
|
const [state, setState] = useState<string>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const sub = jobsApi.api.jobs[id].subscribe();
|
const sub = jobsApi.api.jobs[id].subscribe({ query: init?.query });
|
||||||
ref.current = sub as any;
|
ref.current = sub as any;
|
||||||
|
|
||||||
sub.subscribe(({ data }) =>
|
sub.subscribe(({ data }) =>
|
||||||
|
|
@ -293,23 +297,24 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
{
|
{
|
||||||
case 'error':
|
case 'error':
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
setStatus(status);
|
setState(undefined);
|
||||||
setData(undefined);
|
setData(undefined);
|
||||||
init?.onError?.(data.error);
|
init?.onError?.(data.error);
|
||||||
break;
|
break;
|
||||||
case 'ended':
|
case 'ended':
|
||||||
setStatus(status);
|
setState(undefined);
|
||||||
setData(undefined);
|
setData(undefined);
|
||||||
init?.onEnded?.(data.data as any);
|
init?.onEnded?.(data.data as any);
|
||||||
break;
|
break;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
setStatus(status);
|
setState(undefined);
|
||||||
setData(undefined);
|
setData(undefined);
|
||||||
|
init?.onCompleted?.(data.data as any);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
setData(data.data as DataPayload);
|
setData(data.data as DataPayload);
|
||||||
setStatus(status);
|
setState(data.state);
|
||||||
init?.onProgress?.(data.progress, data.data);
|
init?.onProgress?.(data.progress, data.data as any);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -318,9 +323,9 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
sub.close();
|
sub.close();
|
||||||
ref.current = null;
|
ref.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError]);
|
||||||
|
|
||||||
return { data, status, error, wsRef: ref };
|
return { data, state, error, wsRef: ref };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HandleGoBack ()
|
export function HandleGoBack ()
|
||||||
|
|
|
||||||
10
src/mainview/types.d.ts
vendored
10
src/mainview/types.d.ts
vendored
|
|
@ -6,9 +6,9 @@ declare module "@emulators" {
|
||||||
export default data;
|
export default data;
|
||||||
}
|
}
|
||||||
|
|
||||||
global
|
declare global
|
||||||
{
|
{
|
||||||
declare module "react" {
|
module "react" {
|
||||||
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T>
|
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T>
|
||||||
{
|
{
|
||||||
// extends React's HTMLAttributes
|
// extends React's HTMLAttributes
|
||||||
|
|
@ -18,17 +18,17 @@ global
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FocusParams
|
declare interface FocusParams
|
||||||
{
|
{
|
||||||
onFocus?: (focusKey: string, node: HTMLElement, details: Record<string, any>) => void;
|
onFocus?: (focusKey: string, node: HTMLElement, details: Record<string, any>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractParams
|
declare interface InteractParams
|
||||||
{
|
{
|
||||||
onAction?: (e?: Event) => void;
|
onAction?: (e?: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterOption extends FocusParams, InteractParams
|
declare interface FilterOption extends FocusParams, InteractParams
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface GameMeta
|
||||||
title: string,
|
title: string,
|
||||||
subtitle: string | JSX.Element,
|
subtitle: string | JSX.Element,
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
previewSrcset?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsSchema = z.object({
|
export const SettingsSchema = z.object({
|
||||||
|
|
@ -32,7 +33,9 @@ export const SettingsSchema = z.object({
|
||||||
rommUser: z.string().default('admin').optional(),
|
rommUser: z.string().default('admin').optional(),
|
||||||
windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
|
windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
|
||||||
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
|
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
|
||||||
downloadPath: z.string()
|
downloadPath: z.string(),
|
||||||
|
launchInFullscreen: z.boolean().default(true),
|
||||||
|
disabledPlugins: z.array(z.string()).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LocalSettingsSchema = z.object({
|
export const LocalSettingsSchema = z.object({
|
||||||
|
|
@ -60,43 +63,6 @@ export type DirType = z.infer<typeof DirSchema>;
|
||||||
|
|
||||||
export const CustomEmulatorSchema = z.record(z.string(), z.string());
|
export const CustomEmulatorSchema = z.record(z.string(), z.string());
|
||||||
|
|
||||||
export interface FrontEndId
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FrontEndPlatformType
|
|
||||||
{
|
|
||||||
id: FrontEndId;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
family_name?: string | null;
|
|
||||||
path_cover: string | null;
|
|
||||||
game_count: number;
|
|
||||||
updated_at: Date;
|
|
||||||
hasLocal: boolean;
|
|
||||||
paths_screenshots: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FrontEndGameType
|
|
||||||
{
|
|
||||||
platform_display_name: string | null,
|
|
||||||
path_platform_cover: string | null;
|
|
||||||
id: FrontEndId,
|
|
||||||
source: string | null,
|
|
||||||
source_id: string | null,
|
|
||||||
path_fs: string | null,
|
|
||||||
path_cover: string | null,
|
|
||||||
last_played: Date | null,
|
|
||||||
updated_at: Date,
|
|
||||||
slug: string | null,
|
|
||||||
name: string | null,
|
|
||||||
platform_id: number | null,
|
|
||||||
platform_slug: string | null,
|
|
||||||
paths_screenshots: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GithubManifestSchema = z.object({
|
export const GithubManifestSchema = z.object({
|
||||||
sha: z.hash('sha1'),
|
sha: z.hash('sha1'),
|
||||||
url: z.url(),
|
url: z.url(),
|
||||||
|
|
@ -136,7 +102,8 @@ export const EmulatorPackageSchema = z.object({
|
||||||
pattern: z.string(),
|
pattern: z.string(),
|
||||||
path: z.string().optional()
|
path: z.string().optional()
|
||||||
}))).optional(),
|
}))).optional(),
|
||||||
systems: z.array(z.string())
|
systems: z.array(z.string()),
|
||||||
|
bios: z.literal(["required", "optional"]).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GithubReleaseSchema = z.object({
|
export const GithubReleaseSchema = z.object({
|
||||||
|
|
@ -149,143 +116,6 @@ export const GithubReleaseSchema = z.object({
|
||||||
|
|
||||||
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
|
export type EmulatorPackageType = z.infer<typeof EmulatorPackageSchema>;
|
||||||
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
export type StoreGameType = z.infer<typeof StoreGameSchema>;
|
||||||
export interface EmulatorSourceType
|
|
||||||
{
|
|
||||||
binPath: string;
|
|
||||||
rootPath?: string;
|
|
||||||
type: string;
|
|
||||||
exists: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FrontEndEmulator
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
logo: string;
|
|
||||||
systems: { id: string, name: string, icon: string; }[];
|
|
||||||
gameCount: number;
|
|
||||||
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[];
|
|
||||||
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
|
|
||||||
{
|
|
||||||
summary: string | null;
|
|
||||||
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[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Drive
|
|
||||||
{
|
|
||||||
parent: string | null;
|
|
||||||
device: string;
|
|
||||||
label: string;
|
|
||||||
mountPoint: string | null;
|
|
||||||
type: string;
|
|
||||||
size: number;
|
|
||||||
used: number;
|
|
||||||
isRemovable: boolean;
|
|
||||||
interfaceType: string | null;
|
|
||||||
hasWriteAccess: boolean;
|
|
||||||
hasReadAccess: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadsDrive
|
|
||||||
{
|
|
||||||
device: string;
|
|
||||||
label: string;
|
|
||||||
mountPoint: string | null;
|
|
||||||
isRemovable: boolean;
|
|
||||||
size: number;
|
|
||||||
used: number;
|
|
||||||
isCurrentlyUsed: boolean;
|
|
||||||
unusableReason: 'not_enough_space' | 'already_used' | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Notification
|
|
||||||
{
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
type: 'success' | 'error' | 'info';
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SettingsType = z.infer<typeof SettingsSchema>;
|
||||||
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
||||||
export interface GameInstallProgress
|
|
||||||
{
|
|
||||||
progress?: number;
|
|
||||||
status?: GameStatusType;
|
|
||||||
details?: string;
|
|
||||||
commands?: CommandEntry[];
|
|
||||||
error?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GameInstallProgressEvent = 'refresh';
|
|
||||||
|
|
||||||
export const PlatformSchema = z.object({ slug: z.string() });
|
export const PlatformSchema = z.object({ slug: z.string() });
|
||||||
export const GameLaunchSchema = z.object({ platform: PlatformSchema, id: z.number(), slug: z.string(), directory: z.string() });
|
|
||||||
|
|
||||||
export const GameflowPluginSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
getSupportedPlatform: z.function({ output: z.array(PlatformSchema) }),
|
|
||||||
launchGame: z.function({ input: [GameLaunchSchema] })
|
|
||||||
});
|
|
||||||
export interface GameflowPlugin extends z.infer<typeof GameflowPluginSchema> { }
|
|
||||||
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { GameflowPlugin } from "./constants";
|
|
||||||
|
|
||||||
export type GameflowPluginType = GameflowPlugin;
|
|
||||||
202
src/shared/types..d.ts
vendored
Normal file
202
src/shared/types..d.ts
vendored
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
declare type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded';
|
||||||
|
|
||||||
|
declare interface EmulatorSourceEntryType
|
||||||
|
{
|
||||||
|
binPath: string;
|
||||||
|
rootPath?: string;
|
||||||
|
type: EmulatorSourceType;
|
||||||
|
exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndEmulator
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
systems: { id: string, name: string, icon: string; }[];
|
||||||
|
gameCount: number;
|
||||||
|
validSource?: EmulatorSourceEntryType;
|
||||||
|
integration?: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndEmulatorDetailedDownload
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
type: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndEmulatorDetailed extends FrontEndEmulator
|
||||||
|
{
|
||||||
|
homepage: string;
|
||||||
|
description: string;
|
||||||
|
downloads: FrontEndEmulatorDetailedDownload[];
|
||||||
|
keywords?: string[];
|
||||||
|
screenshots: string[];
|
||||||
|
sources: EmulatorSourceEntryType[];
|
||||||
|
biosRequirement?: "required" | "optional";
|
||||||
|
bios?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndGameTypeDetailedAchievement
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
date?: Date;
|
||||||
|
date_hardcode?: Date;
|
||||||
|
badge_url?: string;
|
||||||
|
display_order: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndGameTypeDetailed extends FrontEndGameType
|
||||||
|
{
|
||||||
|
summary: string | null;
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare interface Drive
|
||||||
|
{
|
||||||
|
parent: string | null;
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
mountPoint: string | null;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
used: number;
|
||||||
|
isRemovable: boolean;
|
||||||
|
interfaceType: string | null;
|
||||||
|
hasWriteAccess: boolean;
|
||||||
|
hasReadAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface DownloadsDrive
|
||||||
|
{
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
mountPoint: string | null;
|
||||||
|
isRemovable: boolean;
|
||||||
|
size: number;
|
||||||
|
used: number;
|
||||||
|
isCurrentlyUsed: boolean;
|
||||||
|
unusableReason: 'not_enough_space' | 'already_used' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontendNotification
|
||||||
|
{
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface CommandEntry
|
||||||
|
{
|
||||||
|
/** The ID of the command. Could be just an index or a string */
|
||||||
|
id: string | number;
|
||||||
|
/** The front end label for the command. Mainly gotten from ES-DE list */
|
||||||
|
label?: string;
|
||||||
|
/** Compiled command to be executed */
|
||||||
|
command: string;
|
||||||
|
/** The path the spawned process will start at */
|
||||||
|
startDir?: string;
|
||||||
|
/** Is the command valid, for example does the executable exists */
|
||||||
|
valid: boolean;
|
||||||
|
/** For what emulator is the command */
|
||||||
|
emulator?: string;
|
||||||
|
/** Where the emulator came from */
|
||||||
|
emulatorSource?: EmulatorSourceType;
|
||||||
|
/** Metadata for the command */
|
||||||
|
metadata: {
|
||||||
|
romPath: string;
|
||||||
|
emulatorBin?: string;
|
||||||
|
/** The root directory of the emulator */
|
||||||
|
emulatorDir?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndId
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndPlatformType
|
||||||
|
{
|
||||||
|
id: FrontEndId;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
family_name?: string | null;
|
||||||
|
path_cover: string | null;
|
||||||
|
game_count: number;
|
||||||
|
updated_at: Date;
|
||||||
|
hasLocal: boolean;
|
||||||
|
paths_screenshots: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndGameType
|
||||||
|
{
|
||||||
|
platform_display_name: string | null,
|
||||||
|
path_platform_cover: string | null;
|
||||||
|
id: FrontEndId,
|
||||||
|
source: string | null,
|
||||||
|
source_id: string | null,
|
||||||
|
path_fs: string | null,
|
||||||
|
path_cover: string | null,
|
||||||
|
last_played: Date | null,
|
||||||
|
updated_at: Date,
|
||||||
|
slug: string | null,
|
||||||
|
name: string | null,
|
||||||
|
platform_id: number | null,
|
||||||
|
platform_slug: string | null,
|
||||||
|
paths_screenshots: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';
|
||||||
|
|
||||||
|
declare interface GameInstallProgress
|
||||||
|
{
|
||||||
|
progress?: number;
|
||||||
|
status?: GameStatusType;
|
||||||
|
details?: string;
|
||||||
|
commands?: CommandEntry[];
|
||||||
|
error?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
|
||||||
|
declare type GameInstallProgressEvent = 'refresh';
|
||||||
|
|
||||||
|
declare interface FrontendPlugin
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
source: PluginSourceType;
|
||||||
|
version: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare type PluginSourceType = "builtin";
|
||||||
|
|
||||||
|
declare type KeysWithValueAssignableTo<T, Value> = {
|
||||||
|
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||||
|
}[keyof T];
|
||||||
Loading…
Add table
Add a link
Reference in a new issue