diff --git a/bun.lock b/bun.lock index e4c0aff..be22c73 100644 --- a/bun.lock +++ b/bun.lock @@ -18,12 +18,14 @@ "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", "jimp": "^1.6.0", + "mustache": "^4.2.0", "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", "systeminformation": "^5.31.1", + "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "ts-igdb-client": "^0.4.2", @@ -48,6 +50,7 @@ "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", + "@types/mustache": "^4.2.6", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -606,6 +609,8 @@ "@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/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=="], + "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=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], diff --git a/package.json b/package.json index a0070eb..23a31de 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,14 @@ "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", "jimp": "^1.6.0", + "mustache": "^4.2.0", "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", "systeminformation": "^5.31.1", + "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "ts-igdb-client": "^0.4.2", @@ -83,6 +85,7 @@ "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", + "@types/mustache": "^4.2.6", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/scripts/package-bun.ts b/scripts/package-bun.ts index f22dd96..e83eb8b 100644 --- a/scripts/package-bun.ts +++ b/scripts/package-bun.ts @@ -1,17 +1,24 @@ import fs from "node:fs/promises"; import path, { } from "node:path"; import os from "node:os"; +import app from '../package.json'; const system = getPlatform(); const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`; const compileOption: Bun.CompileBuildOptions = { outfile: "gameflow", - execArgv: ['--windows-hide-console'], autoloadTsconfig: true, autoloadPackageJson: true, autoloadDotenv: 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) @@ -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('./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 }); diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index b7b4050..39b84ed 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -8,21 +8,21 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; 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 * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; import * as emulatorSchema from "@schema/emulators"; import { login, logout } from "./auth"; import os from 'node:os'; -import { ActiveGame } from "../types/types"; import EventEmitter from "node:events"; -import { ErrorLike } from "bun"; -import { appPath, getErrorMessage } from "../utils"; +import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; import UpdateStoreJob from "./jobs/update-store"; import { getStoreFolder } from "./store/services/gamesService"; +import { PluginManager } from "./plugins/plugin-manager"; +import registerPlugins from "./plugins/register-plugins"; export const config = new Conf({ projectName: projectPackage.name, @@ -31,7 +31,7 @@ export const config = new Conf({ defaults: SettingsSchema.parse({ downloadPath: path.join(os.homedir(), "gameflow"), windowSize: { width: 1280, height: 800 } - } satisfies SettingsType), + }), }); export const customEmulators = new Conf>({ projectName: projectPackage.name, @@ -64,21 +64,9 @@ export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); export const taskQueue = new TaskQueue(); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); await login(); -export let activeGame: ActiveGame | undefined; -export function setActiveGame (game: ActiveGame) -{ - if (activeGame) throw new Error("Only one active game at a time"); - return activeGame = game; -} +export const plugins = new PluginManager(); +registerPlugins(plugins); export const events = new EventEmitter(); -events.addListener('activegameexit', ({ error }) => -{ - activeGame = undefined; - if (error) - { - events.emit('notification', { message: getErrorMessage(error), type: 'error' }); - } -}); config.onDidChange('downloadPath', () => reloadDatabase()); 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]; -} \ No newline at end of file diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts index e714038..2df0dd8 100644 --- a/src/bun/api/drives.ts +++ b/src/bun/api/drives.ts @@ -1,4 +1,3 @@ -import { Drive } from "@/shared/constants"; import si from 'systeminformation'; import fs from 'node:fs'; import os from "node:os"; diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 7f67457..c065657 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,27 +1,27 @@ import Elysia, { status } from "elysia"; -import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm"; -import z, { number } from "zod"; +import { config, db, emulatorsDb, taskQueue } from "../app"; +import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants"; -import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; +import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; 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 { errorToResponse } from "elysia/adapter/bun/handler"; 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 { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; 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 { use } from "react"; import { CACHE_KEYS, getOrCached } from "../cache"; import { host } from "@/bun/utils/host"; +import { LaunchGameJob } from "../jobs/launch-game-job"; // A custom jimp that supports webp 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; }) { - 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') @@ -267,7 +274,7 @@ export default new Elysia() { return { 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')}`, systems: [], gameCount: 0 @@ -312,11 +319,11 @@ export default new Elysia() }) .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') { - 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); } @@ -359,7 +366,7 @@ export default new Elysia() if (validCommand) { // 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 }; } else { @@ -380,13 +387,10 @@ export default new Elysia() }) .post("/stop", async ({ }) => { - if (activeGame) + const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); + if (job) { - events.emit('activegameexit', { - source: 'local', id: String(activeGame.gameId), - exitCode: null, - signalCode: null - }); + job.abort('cancel'); } }) .get('/emulatorjs/data/cores/*', async ({ params }) => @@ -564,6 +568,9 @@ export default new Elysia() if (g.platform_slug === sourceData.platform_slug) rank += 1; + if (g.id.source === 'local') + rank -= 0.2; + if (g.metadata) { if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c))) diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index ee92a3f..64371c0 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -3,7 +3,6 @@ import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRo import z from "zod"; import { and, count, eq, getTableColumns, not } from "drizzle-orm"; import { db } from "../app"; -import { FrontEndPlatformType } from "@shared/constants"; import * as schema from "@schema/app"; import { CACHE_KEYS, getOrCached } from "../cache"; diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 2b7276d..8269285 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -3,103 +3,23 @@ import { which } from 'bun'; import fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; import * as schema from '@schema/emulators'; -import * as appSchema from "@schema/app"; import { eq } from 'drizzle-orm'; -import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app'; +import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; 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 { LaunchGameJob } from '../../jobs/launch-game-job'; export const varRegex = /%([^%]+)%/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({ - 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'); - }*/ + taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } /** @@ -277,11 +197,14 @@ export async function getValidLaunchCommands (data: { let validExec = execs.find(e => e.exists); 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); - return [[value, process.env[key]]]; + return [[value, process.env[key]] as [string, string | undefined]]; })); const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; @@ -311,7 +234,13 @@ export async function getValidLaunchCommands (data: { label: label ?? undefined, command: formattedCommand, 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; })); @@ -328,7 +257,7 @@ export async function findExecsByName (emulatorName: string) 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 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[]; }) { - const execs: EmulatorSourceType[] = []; + const execs: EmulatorSourceEntryType[] = []; if (customEmulators.has(id)) { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 8b56b51..63b621f 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,5 +1,5 @@ -import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants"; -import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app"; +import { RPC_URL, } from "@shared/constants"; +import { config, customEmulators, db, taskQueue } from "../../app"; import { getValidLaunchCommands } from "./launchGameService"; import * as schema from '@schema/app'; import { eq } from "drizzle-orm"; @@ -7,14 +7,13 @@ import { getErrorMessage } from "@/bun/utils"; import { getLocalGameMatch } from "./utils"; import { getRomApiRomsIdGet } from "@/clients/romm"; import fs from 'node:fs/promises'; -import { ErrorLike } from "elysia/universal"; import { getStoreGameFromId } from "../../store/services/gamesService"; import { cores } from "../../emulatorjs/emulatorjs"; import { host } from "@/bun/utils/host"; import Elysia from "elysia"; import z from "zod"; -import data from "@emulators"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; +import { LaunchGameJob } from "../../jobs/launch-game-job"; class CommandSearchError extends Error { @@ -62,7 +61,10 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, - emulator: 'EMULATORJS' + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } }); } @@ -111,19 +113,19 @@ export default function buildStatusResponse () { 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'); } }, async open (ws) { sendLatests(); + const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () { if (ws.readyState > 1) return; - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } }); - const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob); + const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob); if (activeTask) { if (activeTask.status === 'queued') @@ -134,7 +136,7 @@ export default function buildStatusResponse () 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' }); } @@ -189,7 +191,7 @@ export default function buildStatusResponse () } const dispose: Function[] = []; - const handleActiveExit = async (data: { error?: ErrorLike; }) => + const handleActiveExit = async (data: { error?: unknown; }) => { if (data.error) { @@ -200,38 +202,41 @@ export default function buildStatusResponse () } await sendLatests(); }; - events.on('activegameexit', handleActiveExit); - dispose.push(() => events.off('activegameexit', handleActiveExit)); 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 }); } })); 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' }); } })); - 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' }); + } else if (data.job.job instanceof LaunchGameJob) + { + handleActiveExit({}); } })); 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({ status: 'error', error: getErrorMessage(data.error) }); + } else if (data.job.job instanceof LaunchGameJob) + { + handleActiveExit({ error: data.error }); } })); diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 5cc3a96..ca49808 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -4,11 +4,11 @@ import path from "node:path"; import { config, db, emulatorsDb } from "../../app"; import { and, eq } from "drizzle-orm"; 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 * as emulatorSchema from "@schema/emulators"; -import romm from "@/mainview/scripts/queries/romm"; import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; +import { isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; export async function calculateSize (installPath: string | null) { @@ -29,9 +29,10 @@ export function getLocalGameMatch (id: string, source: string) export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType { + const steamDeck = isSteamDeckGameMode(); const game: FrontEndGameType = { 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, updated_at: new Date(rom.updated_at), slug: rom.slug, diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts new file mode 100644 index 0000000..83b797d --- /dev/null +++ b/src/bun/api/hooks/app.ts @@ -0,0 +1,6 @@ +import { GameHooks } from "./emulators"; + +export class GameflowHooks +{ + games = new GameHooks(); +} \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts new file mode 100644 index 0000000..54e783b --- /dev/null +++ b/src/bun/api/hooks/emulators.ts @@ -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']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts new file mode 100644 index 0000000..145d701 --- /dev/null +++ b/src/bun/api/jobs/bios-download-job.ts @@ -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, "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, 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 }; + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 1e4a673..270d1ee 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -10,6 +10,7 @@ import _7z from '7zip-min'; import fs from "node:fs/promises"; import { Downloader } from "@/bun/utils/downloader"; import { move } from "fs-extra"; +import { simulateProgress } from "@/bun/utils"; type EmulatorDownloadStates = "download" | "extract"; @@ -20,11 +21,13 @@ export class EmulatorDownloadJob implements IJob, EmulatorDownloadStates>) @@ -56,44 +59,53 @@ export class EmulatorDownloadJob implements IJob context.setProgress(p, "download"), context.abortSignal); + await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal); + } 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]; - 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) + signal: context.abortSignal, + onProgress (stats) { - const stat = await fs.stat(path.join(emulatorsFolder, contents[0])); - if (stat.isDirectory()) + context.setProgress(stats.progress, 'download'); + }, + }); + + const destinationPaths = await downloader.start(); + if (destinationPaths) + { + if (isArchive) + { + if (await downloader.start() && destinationPaths[0]) + { + let destinationPath = destinationPaths[0]; + await _7z.unpack(destinationPath, emulatorsFolder); + await fs.rm(destinationPath, { recursive: true }); + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(emulatorsFolder); + if (contents.length === 1) { - 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 }); + const stat = await fs.stat(path.join(emulatorsFolder, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpEmulatorsFolder = `${emulatorsFolder} (1)`; + await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true }); + await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true }); + } } } } } } + } exposeData () diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index b095e8f..8ffd7a2 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -6,14 +6,14 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path from 'node:path'; import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm"; -import { config, db, emulatorsDb, events, jar } from "../app"; +import { config, db, emulatorsDb, events } from "../app"; import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; -import { hashFile } from "@/bun/utils"; +import { hashFile, simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; -import { sleep } from "bun"; import _7z from '7zip-min'; +import z from "zod"; interface JobConfig { @@ -25,11 +25,14 @@ export type InstallJobStates = 'download' | 'extract'; export class InstallJob implements IJob { + 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 source: string; public sourceId: string; public config?: JobConfig; - static id = "install-job" as const; + public group = InstallJob.id; constructor(id: string, source: string, sourceId: string, config?: JobConfig) @@ -53,7 +56,6 @@ export class InstallJob implements IJob file_name: string; size?: number; }[] = []; - let cookie: string = ''; let screenshotUrls: string[]; let coverUrl: string; let rommPlatform: PlatformSchema | undefined; @@ -115,7 +117,6 @@ export class InstallJob implements IJob })); files.push(...rommFiles.filter(f => f !== undefined)); - cookie = await jar.getCookieString(config.get('rommAddress') ?? ''); break; case 'store': const game = await getStoreGameFromId(this.gameId); @@ -295,12 +296,7 @@ export class InstallJob implements IJob }); } else { - for (let i = 0; i < 10; i++) - { - cx.setProgress(i * 10, "download"); - if (cx.abortSignal.aborted) return; - await sleep(1000); - } + await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 2c4e3c2..8f836a5 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -1,5 +1,5 @@ import Elysia from "elysia"; -import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod"; +import z, { _ZodType } from "zod"; import { taskQueue } from "../app"; import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; @@ -7,22 +7,27 @@ import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; import { IJob } from "../task-queue"; +import { LaunchGameJob } from "./launch-game-job"; +import { BiosDownloadJob } from "./bios-download-job"; +import { InstallJob } from "./install-job"; function registerJob< const Path extends string, - const Schema extends ZodTypeAny, + const Schema extends z.ZodTypeAny, + const Query extends z.ZodTypeAny, const States extends string, T extends IJob, 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, { body: z.discriminatedUnion('type', [ z.object({ type: z.literal('cancel') }) ]), + query: z.record(z.string(), z.any()), response: z.discriminatedUnion('type', [ z.object({ type: z.literal(['data', 'started', 'progress']), - status: z.string(), + state: z.string().optional(), progress: z.number(), data: _job.dataSchema }), @@ -31,44 +36,45 @@ function registerJob< ]), 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) { - 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 = [ 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 }) => { - 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 }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'completed', data: job.job.exposeData?.() }); } }), taskQueue.on('ended', ({ id, job }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'ended', data: job.job.exposeData?.() }); } }), taskQueue.on('error', ({ id, error }) => { - if (id === _job.id) + if (id === jobId) { ws.send({ type: 'error', error: getErrorMessage(error) }); } @@ -83,7 +89,8 @@ function registerJob< { 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(TwitchLoginJob)) .use(registerJob(UpdateStoreJob)) + .use(registerJob(LaunchGameJob)) + .use(registerJob(BiosDownloadJob)) + .use(registerJob(InstallJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts new file mode 100644 index 0000000..8e97175 --- /dev/null +++ b/src/bun/api/jobs/launch-game-job.ts @@ -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, "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, 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; + } + +} \ No newline at end of file diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index af4d5a6..b10e087 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ 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 { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index 74872c3..cd99584 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -4,10 +4,12 @@ import { getStoreRootFolder } from "../store/services/gamesService"; import { STORE_VERSION } from "@/shared/constants"; import { tmpdir } from "node:os"; import path from "node:path"; +import z from "zod"; export default class UpdateStoreJob implements IJob { static id = "update-store" as const; + static dataSchema = z.never(); packageName: string; registry: URL; storeVersion: string; @@ -27,7 +29,8 @@ export default class UpdateStoreJob implements IJob const storeFolder = getStoreRootFolder(); 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, stdout: 'pipe', stderr: 'pipe', @@ -35,6 +38,13 @@ export default class UpdateStoreJob implements IJob BUN_BE_BUN: "1", 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; } } \ No newline at end of file diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index c20a67d..1a49080 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -1,4 +1,4 @@ -import { Notification } from '@shared/constants'; + import { events } from './app'; export default function buildNotificationsStream () @@ -10,7 +10,7 @@ export default function buildNotificationsStream () { const encoder = new TextEncoder(); - function enqueue (data: Notification, event?: 'notification') + function enqueue (data: FrontendNotification, event?: 'notification') { const evntString = event ? `event: ${event}\n` : ''; controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); @@ -30,7 +30,7 @@ export default function buildNotificationsStream () } }, 15000); - const notificationHandler = (notification: Notification) => + const notificationHandler = (notification: FrontendNotification) => { enqueue(notification, 'notification'); }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini new file mode 100644 index 0000000..cbafaf8 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -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}}} diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json new file mode 100644 index 0000000..bab4f08 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -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" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts new file mode 100644 index 0000000..8539377 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -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) + { + + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts new file mode 100644 index 0000000..9959289 --- /dev/null +++ b/src/bun/api/plugins/plugin-manager.ts @@ -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 = {}; + + 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); + } + })); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts new file mode 100644 index 0000000..e276f92 --- /dev/null +++ b/src/bun/api/plugins/plugins.ts @@ -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() }) + }); \ No newline at end of file diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts new file mode 100644 index 0000000..9226d82 --- /dev/null +++ b/src/bun/api/plugins/register-plugins.ts @@ -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'); + } + })); +} \ No newline at end of file diff --git a/src/bun/api/rpc.ts b/src/bun/api/rpc.ts index 6ad55d3..55ef126 100644 --- a/src/bun/api/rpc.ts +++ b/src/bun/api/rpc.ts @@ -7,15 +7,17 @@ import { system } from "./system"; import { store } from "./store/store"; import { host } from "../utils/host"; import { jobs } from "./jobs/jobs"; +import plugins from "./plugins/plugins"; 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 SettingsAPIType = typeof settings; export type SystemAPIType = typeof system; export type StoreAPIType = typeof store; export type JobsAPIType = typeof jobs; +export type PluginsAPIType = typeof plugins; export function RunAPIServer () { diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 04efda2..6d505f3 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,10 +2,9 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { customEmulators, db, emulatorsDb } from '../app'; -import fs from 'node:fs/promises'; +import { db, emulatorsDb } from '../app'; 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 { host } from '@/bun/utils/host'; diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index d7595bf..753eda3 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,5 +1,5 @@ -import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants"; -import { emulatorsDb } from "../../app"; +import { EmulatorPackageType } from "@/shared/constants"; +import { emulatorsDb, plugins } from "../../app"; import * as emulatorSchema from '@schema/emulators'; import { findExecs } from "../../games/services/launchGameService"; import { eq } from "drizzle-orm"; @@ -10,7 +10,7 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT icon: string; }[]) { - let execPath: EmulatorSourceType | undefined; + let execPath: EmulatorSourceEntryType | undefined; const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); if (esEmulator) @@ -24,8 +24,17 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT logo: emulator.logo, systems, gameCount, - validSource: execPath + validSource: execPath, + integration: findEmulatorPluginIntegration(emulator.name) }; 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 }; } \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index f7e96bc..ab5ba97 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,17 +3,18 @@ import Elysia, { status } from "elysia"; import { config, db, taskQueue } from "../app"; import path from "node:path"; 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 * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; import { CACHE_KEYS, getOrCached, 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 { 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' }) .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 } }) => { - const downlodDir = config.get('downloadPath'); - return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name)); + return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) .get('/emulator/:id', async ({ params: { id } }) => { - const downlodDir = config.get('downloadPath'); const emulatorPackage = await getStoreEmulatorPackage(id); 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 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 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 = { name: emulatorPackage.name, description: emulatorPackage.description, @@ -138,7 +140,10 @@ export const store = new Elysia({ prefix: '/api/store' }) return { name: d.type, type: "Unknown" }; }) ?? []), logo: emulatorPackage.logo, - sources: execPaths + sources: execPaths, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integration: findEmulatorPluginIntegration(emulatorPackage.name) }; return emulator; @@ -154,7 +159,6 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .delete('/emulator/:id', async ({ params: { id } }) => { - const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); if (await fs.exists(storeEmulatorFolder)) { @@ -162,4 +166,24 @@ export const store = new Elysia({ prefix: '/api/store' }) return status("OK"); } 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"); + } }); \ No newline at end of file diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5c592b1..52f1dd5 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -7,7 +7,7 @@ import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; -import { DirSchema, DownloadsDrive } from "@/shared/constants"; +import { DirSchema } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 51f4fd2..e4d8ff5 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,12 +1,12 @@ -import { JobStatus } from '@/shared/constants'; + import EventEmitter from 'node:events'; -import z, { ZodTypeAny } from 'zod'; +import z from 'zod'; export class TaskQueue { - private activeQueue: { context: JobContext, promise?: Promise; }[] = []; - private queue?: { context: JobContext, promise?: Promise; }[] = []; + private activeQueue: { context: JobContext, any, string>, promise?: Promise; }[] = []; + private queue?: { context: JobContext, any, string>, promise?: Promise; }[] = []; private events?: EventEmitter = new EventEmitter(); public enqueue> (id: string, job: T) @@ -36,6 +36,8 @@ export class TaskQueue { const index = this.activeQueue.indexOf(job.job); 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); }); }); @@ -162,7 +164,7 @@ type JobClassWithStatics = JobClass & { export type JobContextFromClass = JobContext< InstanceType, - C extends { dataSchema: ZodTypeAny; } + C extends { dataSchema: z.ZodAny; } ? z.infer : never, C['id'] @@ -215,7 +217,6 @@ export class JobContext, TData, TState extends str } finally { this.running = false; - this.events.emit('ended', { id: this.m_id, job: this }); } } diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index 4ba73c2..b9208d8 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -1,15 +1,4 @@ -import { ChildProcess } from "node:child_process"; - -declare const IS_BINARY: string; - -export type ActiveGame = { - process?: ChildProcess; - gameId: number; - name: string; - command: { command: string, startDir?: string; }; -}; - -interface ObjectConstructor +declare interface ObjectConstructor { /** * Groups members of an iterable according to the return value of the passed callback. @@ -22,7 +11,7 @@ interface ObjectConstructor ): Partial>; } -interface MapConstructor +declare interface MapConstructor { /** * Groups members of an iterable according to the return value of the passed callback. @@ -33,4 +22,10 @@ interface MapConstructor items: Iterable, keySelector: (item: T, index: number) => K, ): Map; +} + +declare interface AppEventMap +{ + exitapp: []; + notification: [FrontendNotification]; } \ No newline at end of file diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts new file mode 100644 index 0000000..09d29e6 --- /dev/null +++ b/src/bun/types/typesc.schema.ts @@ -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; +export type PluginContextType = z.infer; +export type PluginDescriptionType = z.infer; + +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; \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 487719a..f7a2e4e 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,7 +1,9 @@ -import { $ } from 'bun'; +import { $, sleep } from 'bun'; import path from 'node:path'; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; +import { SettingsType } from '@/shared/constants'; +import { config } from './api/app'; export function checkRunning (pid: number) { @@ -111,3 +113,33 @@ export function shuffleInPlace (array: any[], startSeed?: number) [array[i], array[j]] = [array[j], array[i]]; } } + +export function toggleElementInConfig (id: KeysWithValueAssignableTo>, 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); + } +} \ No newline at end of file diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 92a4893..e239905 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -4,7 +4,6 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; -import { file } from "bun"; export interface FileEntry { @@ -24,6 +23,10 @@ interface TmpDownloadMetadata files: FileEntry[]; } +/** + * It download files and reports progress. + * It also automatically applies cookies from the jar store. + */ export class Downloader { files: FileEntry[]; diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 8aa67ab..1482f6f 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -119,16 +119,18 @@ export function AnimatedBackground (data: { > {!data.scrolling &&
-
- {blur && finalLastBackgroundUrl && } + {blur && finalLastBackgroundUrl && } {finalBackgroundUrl ? e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")} > : <>
}
+
} {data.animated && animateBackground &&
{backgroundElements} @@ -147,6 +149,7 @@ export function AnimatedBackground (data: { backgroundColor: "var(--color-base-300)", } : {}}>
+
}
diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index a00d306..1d27805 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -25,6 +25,7 @@ export interface GameCardParams type?: string; subtitle: string | JSX.Element; preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + srcset?: string; focusKey: string; index: number; id: string; @@ -64,7 +65,7 @@ export default function CardElement (data: GameCardParams & InteractParams) data.onAction?.(); }} 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 )} > @@ -75,7 +76,7 @@ export default function CardElement (data: GameCardParams & InteractParams) classNames({ "h-full": typeof data.preview === "string" }) )}> {typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )} diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index f3710ec..306d42d 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -56,6 +56,7 @@ export function CardList (data: { data-index={i} title={g.title} subtitle={g.subtitle ?? ""} + srcset={g.previewSrcset} onFocus={(id, node, details) => { g.onFocus?.(details); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index f9470ef..208f2f9 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -34,7 +34,7 @@ export default function CollectionList (data: { title: g.name, focusKey: `collection-${g.id}`, 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: [ {g.rom_count} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index fa072b0..1b31049 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -7,10 +7,8 @@ import { JSX, Suspense, useEffect } from 'react'; import Shortcuts from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import { PopNavigateSource } from '../scripts/spatialNavigation'; import { GameListFilterType } from '@/shared/constants'; import { GameCardFocusHandler } from './CardElement'; -import { Router } from '..'; import { HandleGoBack } from '../scripts/utils'; export interface CollectionsDetailParams diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 60d6269..c32ccb7 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge"; import { X } from "lucide-react"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { ContextDialogContext } from "../scripts/contexts"; +import { FOCUS_KEYS } from "../scripts/types"; 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 { 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, onFocus: handleFocus, trackChildren: typeof data.content !== 'string' }); 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", - 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", - 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", - 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", - 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", - 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" + 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 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 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 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 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 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) { @@ -47,9 +48,10 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class className={ twMerge("flex cursor-pointer sm:text-sm md:text-base")}> -
+ colors[data.type], + "active:bg-base-content! active:text-base-300! active:transition-none")}> {data.icon} {data.content}
@@ -71,33 +73,34 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { const [open, setOpen] = useState(false); const [sourceFocusKey, setSourceFocusKey] = useState(undefined); - const dialog = + const handleClose = (value: boolean, newSourceFocusKey?: string) => { - setOpen(false); - data.onClose?.(); - }} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}> + if (value === open) return; + if (value) + { + setOpen(true); + setSourceFocusKey(newSourceFocusKey); + } else + { + setOpen(false); + data.onClose?.(); + if (newSourceFocusKey) + { + setFocus(newSourceFocusKey); + } else if (sourceFocusKey) + { + setFocus(sourceFocusKey); + } + } + + }; + const dialog = {data.content} ; return { dialog, open, - setOpen: (value: boolean, sourceFocusKey?: string) => - { - if (value === open) return; - if (value) - { - setOpen(true); - setSourceFocusKey(sourceFocusKey); - } else - { - setOpen(false); - if (sourceFocusKey) - { - setFocus(sourceFocusKey); - } - } - - } + setOpen: handleClose }; } @@ -108,7 +111,6 @@ export function ContextDialog (data: { close: (open: boolean) => void; className?: string; preferredChildFocusKey?: string; - sourceFocusKey?: string; }) { const { ref, focusKey, focusSelf } = useFocusable({ @@ -137,7 +139,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -145,7 +147,7 @@ export function ContextDialog (data: {
Return Home
+
; diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index cf14db2..b0f6608 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; 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 { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { DirType } from "@/shared/constants"; diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 8d37849..0fc2af1 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -2,7 +2,7 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { useGlobalFocus } from "../scripts/spatialNavigation"; -import { JSX, RefObject, useMemo, useState } from "react"; +import { RefObject, useMemo, useState } from "react"; import { useEventListener } from "usehooks-ts"; function ScrollDot (data: { index: number; parent: RefObject, peers: HTMLElement[]; }) diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx new file mode 100644 index 0000000..5a165b1 --- /dev/null +++ b/src/mainview/components/FocusTooltip.tsx @@ -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; visible?: boolean; }) +{ + const [hoverText, setHoverText] = useState(undefined); + const [hoverTextType, setHoverTextType] = useState('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 &&

{hoverText}

; +} \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index ad5751b..533eb29 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -1,4 +1,4 @@ -import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import CardElement from "./CardElement"; import { Router } from ".."; import { FileQuestion, HardDrive, Store } from "lucide-react"; @@ -57,7 +57,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""} - previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""} + previewClassName={data.game.id.source === 'local' ? "dark:not-in-focused:opacity-40 light:not-in-focus:opacity-60" : ""} index={data.index} id={`game-${data.game.id.source}-${data.game.id.id}`} />; diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index a8c421f..8d86dad 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,8 +1,8 @@ -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; 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 { FileQuestion, HardDrive, Store } from "lucide-react"; +import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; import { GameCardFocusHandler } from "./CardElement"; import { useLocalSetting } from "../scripts/utils"; @@ -75,7 +75,7 @@ export function GameList (data: GameListParams) const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "16"); + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); platformUrl.searchParams.set('width', "64"); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 395601f..f79d348 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -14,7 +14,7 @@ import Bell, Bluetooth, Clock, - User, + Settings, Wifi, WifiHigh, WifiLow, @@ -22,70 +22,44 @@ import } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useQuery } from "@tanstack/react-query"; -import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; -import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react"; +import { JSX, RefObject, useEffect, useRef, useState } from "react"; import { systemApi } from "../scripts/clientApi"; import { Router } from ".."; 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: { id: string; - imageSrc?: string | string[]; + preview?: string | JSX.Element; className?: string; active?: boolean; - status?: HeaderAccount['status']; locked?: boolean; - type?: HeaderAccount['type']; 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 (
- {data.imageSrc ? ( + {typeof data.preview === 'string' ? (
- {typeof data.imageSrc === 'string' && } - {Array.isArray(data.imageSrc) && data.imageSrc.map((s, i) => - { - if (i === (data.imageSrc!.length - 1)) - { - return ; - } - return ; - })} + +
- ) : ( - - )} - - + ) : data.preview}
); } @@ -101,7 +75,7 @@ export interface HeaderButton export interface HeaderAccount { id: string; - previewUrl?: string | string[]; + preview?: string | JSX.Element; status?: "status-error" | "status-success" | "status-neutral"; type?: "base" | "primary" | "secondary" | "accent"; locked?: boolean; @@ -228,32 +202,52 @@ function BatteryStatus () export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { - const user = useQuery({ - ...getCurrentUserApiUsersMeGetOptions(), + const rommUser = useQuery({ + ...rommUserQuery(), refetchOnWindowFocus: false, retry: 1 }); + const twitchStatus = useQuery({ + ...twitchLoginVerificationQuery, refetchOnWindowFocus: false, + retry: 1 + }); - const accounts: HeaderAccount[] = [{ - 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 ?? []]; + const { ref } = useFocusable({ focusKey: 'accounts' }); - return
+ 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
{accounts?.map(a => )}
; @@ -273,7 +267,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
{data.buttonElements ?? data.buttons?.map(b => + { + Router.navigate({ to: '/settings/accounts' }); + }; return (
{data.title} - + , id: "settings", action: goToSettings, external: true }]} />
); diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index a7ff050..cdcde29 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,7 +1,6 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; -import { FrontEndId } from "@/shared/constants"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index ec0a638..ea70534 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -25,6 +25,7 @@ export default function NotFound ()
+
; diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 66cbe2f..37edb26 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,4 +1,4 @@ -import { Notification, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { useEffect } from "react"; 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`); 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 }; if (notification.type === 'error') { diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index a3ea021..3689b61 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -22,7 +22,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n } }); 4096; return
- focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" /> + focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
data.onAction?.(e.nativeEvent)}>
; } diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index a1253c5..d8fc94c 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -3,48 +3,48 @@ import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; -const iconMap: Record = { - [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.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[]; }) { + const iconMap: Record = { + [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.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 showKeyboard = control === 'keyboard' || control === 'mouse'; return ( diff --git a/src/mainview/components/game/Achievements.tsx b/src/mainview/components/game/Achievements.tsx index f901b1d..9296403 100644 --- a/src/mainview/components/game/Achievements.tsx +++ b/src/mainview/components/game/Achievements.tsx @@ -1,4 +1,4 @@ -import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/constants"; + import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Medal } from "lucide-react"; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx new file mode 100644 index 0000000..9c9f38c --- /dev/null +++ b/src/mainview/components/game/ActionButton.tsx @@ -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 ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx new file mode 100644 index 0000000..2d62a2e --- /dev/null +++ b/src/mainview/components/game/ActionButtons.tsx @@ -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 +
+
+ + {`${data.game.achievements.unlocked}/${data.game.achievements.total}`} +
+ +
+
; +} + +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: , + content: "Delete", + type: 'error' + }); + } + + const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: }); + + return
+ + + + { + setDetailsSection("achievements"); + if (data.game.achievements?.entires[0]) + { + setFocus(data.game.achievements.entires[0].id); + } + + }} /> + setOpen(true, 'settings')} type="base" id="settings" icon={} > + + {settingsDialog} + + +
; +} \ No newline at end of file diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx new file mode 100644 index 0000000..2c4c187 --- /dev/null +++ b/src/mainview/components/game/Details.tsx @@ -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 ( +
+ {data.icon} + {data.children} +
+ ); +} + +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 = ; + } else if (data.game.missing) + { + fileSizeIcon = ; + } else if (data.game.local) + { + fileSizeIcon = ; + } else + { + fileSizeIcon = ; + } + + return
+ +
+
+ {gameCoverImg ? + : +
+ } +
+
+
+ } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} + {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) && +
+
+ {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)} +
+
} + :
} >{data.game?.platform_display_name ??
}
+ + } > + {data.game?.source ?? data.game?.id.source} + {data.game?.local && local} +
+
+
+ {data.game?.summary ??
+
+
+
+
+
+
+
} +
+ {!!data.game && } +
+
+
+
; +} \ No newline at end of file diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx new file mode 100644 index 0000000..6c914c0 --- /dev/null +++ b/src/mainview/components/game/MainActions.tsx @@ -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(undefined); + const [status, setStatus] = useState(undefined); + const [error, setError] = useState(undefined); + const [details, setDetails] = useState(undefined); + const [commands, setCommands] = useState(undefined); + const [preferredCommand, setPreferredCommand] = useLocalStorage(`${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 = ; + break; + case 'queued': + progressIcon = ; + break; + case 'extract': + progressIcon = ; + 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 =
handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + key="primary" + type='primary' + id="mainAction" + > + + + + + {validCommands.length > 1 && + showAllCommands(true, 'allActionsBtn')}> + + }
; + } + else if (error) + { + mainButton = + { + if (status === 'missing-emulator') + { + Router.navigate({ to: '/settings/directories' }); + } + }} + id="mainAction"> + + ; + } + else + { + mainButton = + { + if (status === 'install') + { + installMut.mutate(); + } + }} + tooltip={details ?? status} + type='primary' + id="mainAction"> + {status === 'install' ? : } + ; + } + + const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { + content: + { + 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: + }); + + return
+ {mainButton} +
+ {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > +
+
+ {progressIcon} +
+ +
+
} + {installOptionsDialog} + {allCommandDialog} +
; +} \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index ce3c44c..1c6e63c 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -11,14 +11,14 @@ import { CSSProperties } from "react"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; const styles = { - base: 'bg-base-200 text-base-content active:bg-base-300! active:text-base-content! active:ring-offset-base-content', - accent: "bg-accent text-accent-content active:bg-base-content! active: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", - secondary: "bg-secondary text-secondary-content active:bg-base-content! active:text-base-content! active:ring-offset-secondary", - info: "bg-info text-info-content active:bg-base-content! active:text-base-content! active:ring-offset-info", - success: "bg-success text-success-content active:bg-base-content! active:text-base-content! active:ring-offset-success", - warning: "bg-warning text-warning-content active:bg-base-content! active:text-base-content! active:ring-offset-warning", - error: "bg-error text-error-content active:bg-base-content! active:text-base-content! active:ring-offset-error", + 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:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent", + 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:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary", + 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:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-success", + 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:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error", }; export function Button (data: { @@ -31,6 +31,8 @@ export function Button (data: { shortcutLabel?: string; focusClassName?: string; cssStyle?: CSSProperties; + tooltip?: string; + tooltipType?: "base" | "accent" | "error"; } & InteractParams & FocusParams) { const { ref, focused, focusKey } = useFocusable({ @@ -49,8 +51,10 @@ export function Button (data: { ref={ref} onClick={e => data.onAction?.(e.nativeEvent)} disabled={data.disabled} + data-tooltip={data.tooltip} + data-tooltip_type={data.tooltipType} 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'], focused ? data.focusClassName : undefined, classNames({ diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 3045e74..071b9f6 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; import { ChevronDown } from "lucide-react"; +import { FOCUS_KEYS } from "@/mainview/scripts/types"; export function OptionDropdown (data: { name: string; @@ -38,7 +39,7 @@ export function OptionDropdown (data: { 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} - {open && + {open && ({ content: v, id: String(i), diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index bd903c6..1f43246 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -11,7 +11,7 @@ export function OptionInput (data: { className?: string; placeholder?: string; icon?: JSX.Element; - value?: string; + value?: string | boolean; defaultValue?: string | boolean; autocomplete?: HTMLInputAutoCompleteAttribute; onBlur?: FocusEventHandler; @@ -58,7 +58,7 @@ export function OptionInput (data: { id={data.name} data-focus={"input"} name={data.name} - value={data.value} + value={String(data.value)} defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined} type={data.type} autoComplete={data.autocomplete} @@ -68,24 +68,22 @@ export function OptionInput (data: { onBlur={data.onBlur} defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined} 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.type === 'checkbox' &&
+ {data.type === 'checkbox' &&
data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)} + onChange={e => data.onChange?.(e.target.checked)} onBlur={data.onBlur} - defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined} className={twMerge( data.className )} diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 5dcef7f..29ba634 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -10,10 +10,6 @@ import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - export interface PathSettingsOptionParams { label: string; @@ -68,11 +64,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { useEffect(() => { - if (!data.isDirty) - { - data.setLocalValue(String(defaultValue)); - } - }, [data.isDirty, defaultValue]); + data.setLocalValue(String(defaultValue)); + }, [defaultValue]); const handleSelectPath = (path: string) => { diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 79cafb0..3775958 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -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 { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - export function SettingsOption (data: { label: string; - id: KeysWithValueAssignableTo; + id: KeysWithValueAssignableTo; type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; @@ -19,10 +15,16 @@ export function SettingsOption (data: { }) { const [dirty, setDirty] = useState(false); - const [localValue, setLocalValue] = useState(); - useQuery(getSettingQuery(data.id)); + const [localValue, setLocalValue] = useState(); + const { data: serverValue } = useQuery(getSettingQuery(data.id)); const setMutation = useMutation(setSettingMutation(data.id)); + useEffect(() => + { + setLocalValue(serverValue as any); + setDirty(false); + }, [serverValue]); + const handleSave = useCallback(() => { if (dirty) @@ -43,7 +45,14 @@ export function SettingsOption (data: { onChange={(v) => { setLocalValue(v); - setDirty(true); + + if (data.type === 'checkbox') + { + setMutation.mutate(v); + } else + { + setDirty(true); + } }} value={localValue} /> diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index 1b0a179..a846406 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -11,7 +11,6 @@ import FocusDots from "../FocusDots"; import { Router } from "@/mainview"; import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; -import { FrontEndEmulator } from "@/shared/constants"; import Carousel from "../Carousel"; function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; }) @@ -51,18 +50,18 @@ export function EmulatorsSection (data: { return ( -
+
{data.header ?? <> -
- -

+
+ +

Recommended Emulators

}
- + {data.emulators?.map((em) => ( data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) => { diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 38327f8..54f4057 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react"; +import { Ref, useEffect, useRef } from "react"; import { useFocusable, @@ -6,7 +6,6 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils"; import FocusDots from "../FocusDots"; -import { FrontEndGameType, FrontEndId } from "@/shared/constants"; import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index 0613d3a..b064ec8 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -7,7 +7,7 @@ import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react"; 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"; // ── Single missing-emulator card ─────────────────────────────────────────── diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index f6dd24c..78fd7d4 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -1,11 +1,11 @@ import { twMerge } from "tailwind-merge"; -import { FrontEndEmulator, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; import { Button } from "../options/Button"; import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { ChevronRight, EllipsisVertical, 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 { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { JSX } from "react"; @@ -54,14 +54,13 @@ export function StoreEmulatorCard (data: {
-

{data.emulator.name}

+

{data.emulator.name}

    {data.emulator.systems.map(({ id, name, icon }) => { @@ -75,15 +74,15 @@ export function StoreEmulatorCard (data: {
-
+
+ {!!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
+
+
} {!!data.emulator.validSource &&
-
+
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
} - {data.emulator.gameCount > 0 &&
-
{data.emulator.gameCount}
-
} {isMouse && <> diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index ce99e6c..61e570b 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -61,4 +61,4 @@ const moduleUrls = import.meta.glob // 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()]))); -await import('@emulatorjs/emulatorjs/data/loader.js'); \ No newline at end of file +await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 38b4102..d730600 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './../routes/__root' import { Route as GamesRouteImport } from './../routes/games' import { Route as SettingsRouteRouteImport } from './../routes/settings/route' 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 SettingsEmulatorsRouteImport } from './../routes/settings/emulators' import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' @@ -43,6 +44,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({ id: '/interface', path: '/interface', @@ -130,6 +136,7 @@ export interface FileRoutesByFullPath { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -149,6 +156,7 @@ export interface FileRoutesByTo { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -170,6 +178,7 @@ export interface FileRoutesById { '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute + '/settings/plugins': typeof SettingsPluginsRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute @@ -192,6 +201,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -211,6 +221,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -231,6 +242,7 @@ export interface FileRouteTypes { | '/settings/directories' | '/settings/emulators' | '/settings/interface' + | '/settings/plugins' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' @@ -277,6 +289,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/settings/plugins': { + id: '/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof SettingsPluginsRouteImport + parentRoute: typeof SettingsRouteRoute + } '/settings/interface': { id: '/settings/interface' path: '/interface' @@ -391,6 +410,7 @@ interface SettingsRouteRouteChildren { SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute } const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { @@ -399,6 +419,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsDirectoriesRoute: SettingsDirectoriesRoute, SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, + SettingsPluginsRoute: SettingsPluginsRoute, } const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( diff --git a/src/mainview/index.css b/src/mainview/index.css index f4be94e..3bad22c 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -3,6 +3,7 @@ @plugin "daisyui"; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@custom-variant light (&:where([data-theme=light], [data-theme=light] *)); @theme { --breakpoint-sm: 0px; @@ -194,6 +195,7 @@ html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; + background-color: var(--color-base-100); } body { @@ -344,18 +346,21 @@ body { width: 100%; height: 100%; z-index: -1; + background-repeat: repeat; --bg-gradient-opacity: 15%; - background: - radial-gradient(at 10% 20%, rgb(from var(--color-error) 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 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%); + @variant dark { + background: + radial-gradient(at 10% 20%, rgb(from var(--color-error) 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 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; - background-repeat: repeat; - background-color: var(--color-base-100); - @apply mobile:hidden; + @variant light { + background-color: var(--color-base-300); + } } .bg-noise { @@ -368,6 +373,26 @@ body { 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-opacity: 90%; @@ -407,22 +432,22 @@ body { html:active-view-transition-type(zoom-in) { &::view-transition-old(root) { - animation: fade-out 300ms ease-in forwards; + animation: fade-out 200ms ease-in forwards; } &::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) { &::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) { - animation: zoom-start-small-in-fade-in 300ms ease-in-out forwards; + animation: zoom-start-small-in-fade-in 200ms ease-out forwards; } } diff --git a/src/mainview/index.html b/src/mainview/index.html index d468edc..43c30a6 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 73c5fa6..243d778 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,6 +4,7 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; +import { useEffect } from "react"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -14,9 +15,24 @@ function RootComponent () const isMobile = mobileCheck(); const theme = useLocalSetting('theme'); 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 ( -
+
diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 3d35ec4..1969410 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,23 +1,16 @@ import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; -import { CommandEntry, RPC_URL } from "@shared/constants"; -import { twMerge } from "tailwind-merge"; -import { JSX, RefObject, useEffect, useRef, useState } from "react"; +import { RPC_URL } from "@shared/constants"; +import { useEffect, useRef, useState } from "react"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import classNames from "classnames"; -import { Calendar, Clock, CloudDownload, Download, EllipsisVertical, Folder, Gamepad2, HardDrive, Image, Info, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react"; +import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; -import prettyBytes from 'pretty-bytes'; -import { useFocusEventListener } from "../../scripts/spatialNavigation"; import { AnimatedBackground } from "../../components/AnimatedBackground"; -import toast from "react-hot-toast"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Router } from "../.."; -import { ContextDialog, ContextList, DialogEntry, useContextDialog } from "../../components/ContextDialog"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; -import useActiveControl from "@/mainview/scripts/gamepads"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; @@ -25,19 +18,17 @@ import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; import Achievements from "@/mainview/components/game/Achievements"; -import { getErrorMessage } from "react-error-boundary"; import { GameDetailsContext } from "@/mainview/scripts/contexts"; -import { rommApi } from "@/mainview/scripts/clientApi"; -import { deleteGameMutation, gameQuery, gamesRecommendedBasedOnGameQuery, installMutation, playMutation } from "@queries/romm"; +import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; +import Details, { DetailElement } from "@/mainview/components/game/Details"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => { - const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id)); - return { data }; + context.queryClient.prefetchQuery(gameQuery(params.source, params.id)); }, - component: GameDetailsUI, + component: RouteComponent, pendingComponent: GameDetailsUIPending, errorComponent: Error, validateSearch: zodValidator(z.object({ focus: z.string().optional() })) @@ -92,13 +83,13 @@ function MainDetailsPending ()
- } > - } >
- } > + } >
+ } > -
+
@@ -155,9 +146,8 @@ function GameDetailsUIPending () ; } -function MoreDetails (data: {}) +function MoreDetails (data: { game: FrontEndGameTypeDetailed | undefined; }) { - const { data: game } = Route.useLoaderData(); const [details] = useDetailsSection(); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: "game-more-details-section", @@ -167,456 +157,41 @@ function MoreDetails (data: {}) return
- +
- {details === 'screenshots' &&
} - {details === 'stats' && } - {details === 'achievements' && } + {details === 'screenshots' && !!data.game &&
} + {details === 'stats' && } + {details === 'achievements' && !!data.game && }
; } -function Details (data: { mainAreaRef: RefObject; }) +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 = ; - } else if (game.missing) - { - fileSizeIcon = ; - } else if (game.local) - { - fileSizeIcon = ; - } else - { - fileSizeIcon = ; - } - - return
- -
-
- {gameCoverImg ? - : -
- } -
-
-
- } >{game?.last_played ? new Date(game.last_played).toDateString() : "Never"} - {!!game && (game.fs_size_bytes !== null || game.missing) && -
-
- {game.missing ? 'Missing' : prettyBytes(game.fs_size_bytes!)} -
-
} - } >{game?.platform_display_name ??
}
- - } > - {game?.source ?? game?.id.source} - {game?.local && local} -
-
-
- {game?.summary ??
-
-
-
-
-
-
-
} -
- {!!game && } -
-
-
-
; -} - -function AchievementsInfo (data: InteractParams) -{ - const { data: game } = Route.useLoaderData(); - if (!game.achievements) - { - return false; - } - - return -
-
- - {`${game.achievements.unlocked}/${game.achievements.total}`} -
- -
-
; -} - -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(undefined); - const [status, setStatus] = useState(undefined); - const [error, setError] = useState(undefined); - const [details, setDetails] = useState(undefined); - const [commands, setCommands] = useState(undefined); - const [preferredCommand, setPreferredCommand] = useLocalStorage(`${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 = ; - break; - case 'queued': - progressIcon = ; - break; - case 'extract': - progressIcon = ; - 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 =
handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} - key="primary" - type='primary' - id="mainAction" - > - - - - - {validCommands.length > 1 && - showAllCommands(true, 'allActionsBtn')}> - - }
; - } - else if (error) - { - mainButton = - { - if (status === 'missing-emulator') - { - Router.navigate({ to: '/settings/directories' }); - } - }} - id="mainAction"> - - ; - } - else - { - mainButton = - { - if (status === 'install') - { - installMut.mutate(); - } - }} - tooltip={details ?? status} - type='primary' - id="mainAction"> - {status === 'install' ? : } - ; - } - - const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { - content: - { - 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: - }); - - return
- {mainButton} -
- {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > -
-
- {progressIcon} -
- -
-
} - {installOptionsDialog} - {allCommandDialog} -
; -} - -function ActionButtons (data: {}) -{ - const [, setDetailsSection] = useDetailsSection(); - const { data: game } = Route.useLoaderData(); - const [hoverText, setHoverText] = useState(undefined); - const [hoverTextType, setHoverTextType] = useState('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: , - 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
- - - - { - setDetailsSection("achievements"); - if (game.achievements?.entires[0]) - { - setFocus(game.achievements.entires[0].id); - } - - }} /> - setOpen(true)} type="base" id="settings" icon={} > - - - - - - {!!hoverText && !isPointer &&

{hoverText}

} -
-
; -} - -function Detail (data: { icon: JSX.Element; children?: any | any[]; }) -{ - return ( -
- {data.icon} - {data.children} -
- ); -} - -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 ( -
- -
- ); -} - -function Stats () -{ - const { data } = Route.useLoaderData(); const stats: StatEntry[] = []; - if (data.path_fs) - stats.push({ label: "Location", content: data.path_fs, icon: }); - if (data.companies) - stats.push({ label: "Companies", content: data.companies }); - if (data.genres) - stats.push({ label: 'Genres', content: data.genres }); - if (data.release_date) - stats.push({ label: "Release Date", content: data.release_date.toLocaleDateString(), icon: }); - if (data.emulators) - stats.push({ label: "Emulators", content: data.emulators.map(e => e.name) }); - return ; + if (data.game) + { + if (data.game.path_fs) + stats.push({ label: "Location", content: data.game.path_fs, icon: }); + if (data.game.companies) + stats.push({ label: "Companies", content: data.game.companies }); + if (data.game.genres) + stats.push({ label: 'Genres', content: data.game.genres }); + if (data.game.release_date) + stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.emulators) + stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + } + + return ; } -function Divider (data: { rootFocusKey: string; showShortcuts: boolean; }) +function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: FrontEndGameTypeDetailed | undefined; }) { const [details, setDetails] = useDetailsSection(); - const { data: game } = Route.useLoaderData(); const { ref, focusKey } = useFocusable({ focusKey: "details-divider", onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'nearest', behavior: 'smooth' })(focusKey, ref.current, d), @@ -625,7 +200,7 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; }) stats: { label: "Stats", selected: details === 'stats', icon: }, screenshots: { label: "Screenshots", selected: details === 'screenshots', icon: }, }; - if (game.achievements) + if (data.game?.achievements) { detailFilter.achievements = { label: "Achievements", selected: details === 'achievements', icon: }; } @@ -637,18 +212,18 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; })
; } -export default function GameDetailsUI () +function RouteComponent () { 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 [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); const headerRef = useRef(null); const sentinelRef = useRef(null); - const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined; - const mainAreaRef = useRef(null); - const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data.id.source, data.id.id), enabled: recommendedGamesVisible }); + const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; + const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); @@ -666,7 +241,7 @@ export default function GameDetailsUI () }, []); 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({ onChange: (isIntersecting, entry) => @@ -686,13 +261,14 @@ export default function GameDetailsUI ()
-
-
+
+
- -
+ +
+
{!!recommendedEmulators && recommendedEmulators.length > 0 &&

Related Emulators @@ -703,6 +279,7 @@ export default function GameDetailsUI () Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); }} emulators={recommendedEmulators} />} +

diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 9f2bf7b..6ac40a5 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -1,4 +1,4 @@ -import { JSX, Suspense, useContext, useEffect, useState } from "react"; +import { JSX, Suspense, useContext, useState } from "react"; import { Gamepad2, @@ -14,7 +14,6 @@ import import { createFileRoute, - useNavigate, } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; import @@ -25,7 +24,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { useEventListener } from "usehooks-ts"; -import { HeaderAccounts, HeaderStatusBar } from "../components/Header"; +import { HeaderAccounts, HeaderButton, HeaderStatusBar } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground } from "../components/AnimatedBackground"; import { GameList } from "../components/GameList"; @@ -43,7 +42,6 @@ import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; -import { FrontEndId } from "@/shared/constants"; import Carousel from "../components/Carousel"; 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 { shortcuts } = useShortcutContext(); - const headerButtons = []; + const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); - headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate() }); + headerButtons.push( + { id: "search-header-button", icon: }, + { id: "power-button", icon: , external: true, action: () => close.mutate() }, + { id: "settings-header-button", icon: , external: true, action: () => Router.navigate({ to: "/settings/accounts" }) } + ); return ( diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index b9bb2a5..89c4a65 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,6 +1,5 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { createFileRoute } from '@tanstack/react-router'; -import { GameInstallProgress, RPC_URL } from '@/shared/constants'; import DotsLoading from '../components/backgrounds/dots'; import { Router } from '..'; import { useEffect } from 'react'; @@ -9,6 +8,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts from '../components/Shortcuts'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -30,30 +30,22 @@ function RouteComponent () 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 (stats.status !== 'playing') + if (e.data.status !== 'playing') { HandleGoBack(); } - }; - - es.addEventListener('refresh', () => - { - HandleGoBack(); }); - es.onerror = () => + return () => { - HandleGoBack(); + sub.close(); }; - - return () => es.close(); - }, []); - + }, [data?.id]); return
diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index a755625..5a32eec 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -2,7 +2,6 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga import { Block, createFileRoute } from '@tanstack/react-router'; import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; -import { DownloadsDrive } from '@/shared/constants'; import prettyBytes from 'pretty-bytes'; import classNames from 'classnames'; 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 useActiveControl from '@/mainview/scripts/gamepads'; import { changeDownloadsMutation } from '@queries/settings'; +import { downloadDrivesQuery } from '@/mainview/scripts/queries/system'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, @@ -79,8 +79,8 @@ function RouteComponent () preferredChildFocusKey: focus }); - const isMoving = useIsMutating(queries.settings.changeDownloadsMutation); - const { data: drives, refetch } = useQuery({ ...queries.system.downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); + const isMoving = useIsMutating(changeDownloadsMutation); + const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); return isMoving > 0} withResolver={false} /> diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 44e4e76..3ed6ead 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; 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 { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react'; 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 { dirname } from 'pathe'; 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')({ component: RouteComponent, @@ -99,6 +103,7 @@ function EmulatorPath (data: { id: string; }) const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); const { data: remoteValue } = useQuery(customEmulatorRemoveValueQuery(data.id)); + useEffect(() => { setLocalValue(remoteValue); }, [remoteValue]); const setSettingMutation = useMutation(setCustomEmulatorMutation(data.id, (v) => { setLocalValue(v); @@ -128,7 +133,7 @@ function EmulatorPath (data: { id: string; }) }; return ( - <>

{data.id}

{emulators[data.id]} @@ -140,7 +145,6 @@ function EmulatorPath (data: { id: string; }) type="text" onBlur={handleSave} autocomplete="off" - defaultValue={remoteValue} onChange={(v) => { setLocalValue(v); @@ -187,22 +191,22 @@ function EmulatorBadge (data: { isCritical: boolean; pathCover?: string; addOverride: (emulator: string) => void; -}) +} & FocusParams) { const { focusKey, ref, focused } = useFocusable({ - focusKey: `badge-${data.emulator}`, onFocus: () => - { - (ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } + focusKey: FOCUS_KEYS.EMULATOR_CARD(data.emulator), + onFocus (l, p, details) { data.onFocus?.(focusKey, ref.current, details); } }); useShortcuts(focusKey, () => [{ - label: 'Add Override', button: GamePadButtonCode.A, action: () => + label: 'Add Override', + button: GamePadButtonCode.A, + action: () => data.addOverride(data.emulator) }], [data.addOverride]); - return
-
+
; } -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 { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 }); - return
+ const { data: autoEmulators } = useQuery({ + ...autoEmulatorsQuery, + 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 + - {autoEmulators?.map(e => )} + {autoEmulators?.map(e => scrollIntoNearestParent(n)} key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.validSource?.binPath} exists={!!e.validSource} emulator={e.name} />)} + -
; + ; } function RouteComponent () @@ -242,11 +269,19 @@ function RouteComponent () 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
    - + +
    Preferences
    +
    Overrides
    {!!customEmulators && customEmulators.map((key) => )} diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx new file mode 100644 index 0000000..bccca6a --- /dev/null +++ b/src/mainview/routes/settings/plugins.tsx @@ -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 +
    + {data.plugin.icon ? : } +
    +
    +
    {data.plugin.displayName}
    +
    {data.plugin.name} ({data.plugin.version})
    +
    +
} className='flex p-4 bg-base-200 rounded-3xl'> + + + ; +} + +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 <> +
{source === 'builtin' ? "Built In" : "Store"}
+ {plugins.map(p => pluginMutation.mutate({ id: p.name, enabled: v })} />)} + ; + })} + ; +} diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 2fe2467..c6e8198 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -19,6 +19,7 @@ import Info, Joystick, MonitorCog, + Puzzle, } from "lucide-react"; import { JSX, useEffect } from "react"; import { twMerge } from "tailwind-merge"; @@ -141,6 +142,12 @@ function SettingsMenu (data: {}) label="Directories" icon={} /> + } + /> { if (data.homepage) systemApi.api.system.open.post({ url: data.homepage }); @@ -58,10 +59,44 @@ function TitleArea (data: { { const queryClient = useQueryClient(); 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: }); + }, }); const installProgressRef = useRef(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: }); + }, + onEnded (data) + { + queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator)); + }, + }); + const { data: installJob, state: installState } = useJobStatus('download-emulator', { onError (error) { console.log(error); @@ -80,12 +115,13 @@ function TitleArea (data: { }, }); - const isInstalling = !!installJob; + const isInstalling = !!installJob || !!biosInstallJob; const options: DialogEntry[] = []; + const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); if (data.emulator) { - if (!isInstalling && !data.emulator?.validSource) + if (!isInstalling && !installedFromStore) { options.push(...data.emulator.downloads.map(d => { @@ -101,7 +137,7 @@ function TitleArea (data: { }; return entry; })); - } else if (data.emulator.sources.find(s => s.type === 'store' && s.exists)) + } else if (installedFromStore) { options.push({ content: "Delete", @@ -114,12 +150,43 @@ function TitleArea (data: { }, id: "delete" }); + + if (!data.emulator.bios || data.emulator.bios.length <= 0) + { + options.push({ + content: "Download BIOS", + type: "primary", + icon: , + action (ctx) + { + downloadBios.mutate(); + ctx.close(); + }, + id: "download-bios" + }); + } else + { + options.push({ + content: "Delete BIOS", + type: "error", + icon: , + 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', preferredChildFocusKey: "install-btn", + trackChildren: true, onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); } }); @@ -131,7 +198,16 @@ function TitleArea (data: { } else if (isInstalling) { - installButtonContent = <>{installStatus}; + const status: any = { + bios: { + download: "Downloading BIOS" + }, + install: { + download: "Downloading", + extract: "Extracting" + } + }; + installButtonContent = <>{installState ? status.install[installState] : biosDownloadState ? status.bios[biosDownloadState] : undefined}; } else if (data.emulator.validSource) { installButtonContent = <> Options; @@ -155,25 +231,37 @@ function TitleArea (data: { return
- {data.emulator ? :
} + {data.emulator ? :
}
-

{data.emulator?.name ??
}

+

{data.emulator?.name ??
}

{data.emulator?.systems.map(({ id, name, icon }) => { return
{!!icon && } -

{name}

+

{name}

; }) ?? <>
}
+
+ {!!data.emulator?.bios?.[0] &&
+
+
} + {data.emulator && !!data.emulator.integration && data.emulator.validSource?.type === 'store' &&
+
+
}
-
- +
} +