feat: implemented a basic store and emulatorjs

This commit is contained in:
Simeon Radivoev 2026-03-14 02:15:57 +02:00
parent 2f32cbc730
commit 7286541822
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
121 changed files with 5900 additions and 1092 deletions

View file

@ -10,10 +10,10 @@ import Conf from "conf";
import projectPackage from '~/package.json';
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
import { client } from "@clients/romm/client.gen";
import * as schema from "./schema/app";
import * as emulatorSchema from "./schema/emulators";
import * as schema from "@schema/app";
import cacheSchema from "@schema/cache";
import * as emulatorSchema from "@schema/emulators";
import { login, logout } from "./auth";
import fs from 'node:fs/promises';
import os from 'node:os';
import { ActiveGame } from "../types/types";
import EventEmitter from "node:events";
@ -21,6 +21,7 @@ import { ErrorLike } from "bun";
import { appPath, getErrorMessage } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra";
import UpdateStoreJob from "./jobs/update-store";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
@ -50,7 +51,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path),
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);
let sqlite: Database;
export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
let cacheSqlite: Database;
export let db: DrizzleSqliteDODatabase<typeof schema>;
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
await reloadDatabase();
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
@ -73,6 +77,7 @@ events.addListener('activegameexit', ({ error }) =>
}
});
config.onDidChange('downloadPath', () => reloadDatabase());
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
export async function cleanup ()
{
@ -86,13 +91,25 @@ export async function reloadDatabase ()
{
await ensureDir(config.get('downloadPath'));
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
await ensureDir(path.join(os.tmpdir(), 'gameflow'));
console.log("Loaded Cache from: ", cachePath);
cacheSqlite = new Database(cachePath, { create: true, readwrite: true });
db = drizzle(sqlite, { schema });
cache = drizzle(cacheSqlite, { schema: cacheSchema });
migrate(db!, { migrationsFolder: appPath("./drizzle") });
cache.run(`
CREATE TABLE IF NOT EXISTS item_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expire_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
interface AppEventMap
{
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
exitapp: [];
notification: [Notification];
}

View file

@ -1,45 +1,117 @@
import Elysia, { sse, status } from "elysia";
import { config, jar, taskQueue } from "./app";
import { config, events, jar, taskQueue } from "./app";
import z from "zod";
import { client } from "@clients/romm/client.gen";
import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
import secrets from '../api/secrets';
import { LoginJob } from "./jobs/login-job";
import TwitchLoginJob from "./jobs/twitch-login-job";
export default new Elysia()
.post('/login/remote/start', async () =>
.post('/login/twitch', async ({ body: { openInBrowser } }) =>
{
if (taskQueue.hasActiveOfType(TwitchLoginJob))
{
return status("Conflict", `Twitch Authentication already in progress`);
}
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
return taskQueue.enqueue(TwitchLoginJob.id, new TwitchLoginJob(process.env.TWITCH_CLIENT_ID, openInBrowser ?? false));
},
{ body: z.object({ openInBrowser: z.boolean().optional() }) })
.post('/logout/twitch', async () =>
{
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
const res = await fetch('https://id.twitch.tv/oauth2/revoke', {
method: "POST", headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID
})
});
await secrets.delete({ service: 'gamflow_twitch', name: 'access_token' });
await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' });
await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' });
return status(res.status, res.statusText);
})
.get('/login/twitch', async () =>
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (!access_token)
{
return status('Not Found', "Not Logged In");
}
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
if (!process.env.TWITCH_CLIENT_ID)
{
return status("Not Found", "Twitch Client ID not set");
}
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
if (!refresh_token)
{
return status("Not Found", "Refresh Token Not Found");
}
// refresh token
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
client_id: process.env.TWITCH_CLIENT_ID,
access_token,
grant_type: "refresh_token",
refresh_token
})
});
if (refreshResponse.ok)
{
const data: {
access_token: string,
refresh_token: string,
token_type: string;
expires_in: number;
} = await refreshResponse.json();
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
if (res.ok)
{
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
}
}
return status(400, res.statusText);
})
.post('/login/romm', async () =>
{
if (taskQueue.hasActiveOfType(LoginJob))
{
return status("Conflict", "Login Already Active");
}
const job = new LoginJob();
taskQueue.enqueue("login", job);
return status("OK");
})
.get('/login/remote/status', async function* ()
{
const job = taskQueue.findJob("login");
if (job)
{
const loginJob = job.job as LoginJob;
yield sse({ data: { endsAt: loginJob.endsAt, url: loginJob.url } });
await taskQueue.waitForJob('login');
yield sse({ data: {} });
}
yield sse({ data: {} });
})
.post('/login/remote/cancel', async () =>
{
const job = taskQueue.findJob("login");
if (job)
{
job.abort("cancel");
await taskQueue.waitForJob('login');
}
return {};
return taskQueue.enqueue(LoginJob.id, new LoginJob());
})
.post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>

34
src/bun/api/cache.ts Normal file
View file

@ -0,0 +1,34 @@
import { eq } from "drizzle-orm";
import { cache } from "./app";
import cacheSchema from "@schema/cache";
export const CACHE_KEYS = {
ROM_PLATFORMS: 'rom-platforms',
STORE_GAME: (path: string) => `store-game-${path}`,
STORE_GAME_MANIFEST: 'store-game-manifest'
} as const;
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
{
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
const updated_at = new Date();
if (cached && cached.expire_at > updated_at)
{
return cached.data as T;
}
const data = await getter();
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
await cache.insert(cacheSchema.item_cache)
.values({ key, data, updated_at, expire_at })
.onConflictDoUpdate({
target: cacheSchema.item_cache.key,
set: { data, updated_at, expire_at }
})
.run();
return data;
}

View file

@ -0,0 +1,46 @@
// ES-DE to emulator JS mapping
// TODO: use the retroarch cores based on ES-DE
export const cores: Record<string, string> = {
"atari5200": "atari5200",
"virtualboy": "vb",
"nds": "nds",
"arcade": "arcade",
"nes": "nes",
"gb": "gb",
"gbc": "gb",
"colecovision": "coleco",
"mastersystem": "segaMS",
"megadrive": "segaMD",
"gamegear": "segaGG",
"segacd": "segaCD",
"sega32x": "sega32x",
"genesis": "sega",
"mark3": "sega",
"megacd": "sega",
"megacdjp": "sega",
"megadrivejp": "sega",
"sg-1000": "sega",
"atarilynx": "lynx",
"mame": "mame",
"ngp": "ngp",
"supergrafx": "pce",
"pcfx": "pcfx",
"psx": "psx",
"wonderswan": "ws",
"gba": "gba",
"n64": "n64",
"3do": "3do",
"psp": "psp",
"atari7800": "atari7800",
"snes": "snes",
"atari2600": "atari2600",
"atarijaguar": "jaguar",
"saturn": "segaSaturn",
"amiga": "amiga",
"c64": "c64",
"c128": "c128",
"pet": "pet",
"plus4": "plus4",
"vic20": "vic20",
"dos": "dos"
};

View file

@ -1,23 +1,32 @@
import Elysia, { status } from "elysia";
import { config, db, taskQueue } from "../app";
import { activeGame, config, db, events, taskQueue } from "../app";
import { and, eq, getTableColumns, sql } from "drizzle-orm";
import z from "zod";
import * as schema from "../schema/app";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import { Jimp } from 'jimp';
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
// A custom jimp that supports webp
const Jimp = createJimp({
formats: [...defaultFormats, webp],
plugins: defaultPlugins,
});
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
{
if (blur)
if (blur && !noBlur)
{
const jimp = await Jimp.read(img);
if (width)
@ -48,6 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
if (!coverBlob || !coverBlob.cover)
{
@ -71,7 +82,7 @@ export default new Elysia()
return processImage(`${rommAdress}/${path}`, query);
}
return status('Not Found');
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) })
.get('/image', async ({ query }) =>
{
return processImage(query.url, query);
@ -106,18 +117,24 @@ export default new Elysia()
}, {
params: z.object({ id: z.number() }),
response: z.object({ installed: z.boolean() })
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
})
.get('/games', async ({ query, set }) =>
{
const where: any[] = [];
if (platform_slug)
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, platform_slug));
where.push(eq(schema.platforms.slug, query.platform_slug));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const games: FrontEndGameType[] = [];
let localGamesSet: Set<number> | undefined;
let localGamesSet: Set<string> | undefined;
if (!collection_id)
if (!query.collection_id)
{
const localGames = await db.select({
...getTableColumns(schema.games),
@ -128,45 +145,87 @@ export default new Elysia()
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.offset(query.offset ?? 0)
.limit(query.limit ?? 50)
.where(and(...where));
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
{
const game: FrontEndGameType = {
platform_display_name: g.platform?.name ?? "Local",
id: { id: g.id, source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
path_fs: g.path_fs,
last_played: g.last_played,
slug: g.slug,
name: g.name,
platform_id: g.platform_id
};
return game;
return convertLocalToFrontend(g);
}));
}
if ((!platform_source || platform_source === 'romm') || !!collection_id)
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id,
limit: query.limit,
offset: query.offset
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
{
return convertRomToFrontend(g);
}));
}
if (query.source === 'store')
{
const gamesManifest = await getStoreGameManifest();
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
const storeGames = await Promise.all(gamesManifest
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
}
return { games };
}, {
query: GameListFilterSchema,
})
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
{
const localGame = await db.query.games.findFirst({
where: getLocalGameMatch(id, source),
columns: { path_fs: true }
});
if (!localGame?.path_fs)
{
return status("Not Found");
}
const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs);
const stats = await fs.stat(path_fs);
if (stats.isDirectory())
{
return status("Not Found", "Rom is a folder");
}
return Bun.file(path_fs);
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
async function getLocalGameDetailed (match: any)
@ -175,7 +234,7 @@ export default new Elysia()
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true } }
platform: { columns: { name: true, slug: true } }
}
});
if (localGame)
@ -185,7 +244,7 @@ export default new Elysia()
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: localGame.id, source: 'local' },
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
@ -199,7 +258,8 @@ export default new Elysia()
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
}
@ -209,7 +269,7 @@ export default new Elysia()
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return status('Not Found');
}
@ -218,18 +278,30 @@ export default new Elysia()
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (localGame) return localGame;
const rom = await getRomApiRomsIdGet({ path: { id } });
if (rom.data)
if (source === 'romm')
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
}
return status("Not Found", rom.response);
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return status("Not Found");
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
}
return status("Not Found", rom.response);
return status("Not Found");
}
}, {
params: z.object({ source: z.string(), id: z.coerce.number() })
params: z.object({ source: z.string(), id: z.string() })
})
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
{
@ -239,7 +311,7 @@ export default new Elysia()
return buildStatusResponse(source, id);
}, {
response: z.any(),
params: z.object({ id: z.coerce.number(), source: z.string() }),
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ isLocal: z.boolean().optional() })
})
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
@ -253,36 +325,51 @@ export default new Elysia()
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
params: z.object({ id: z.string(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
if (!taskQueue.hasActive())
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
return status(200);
if (source === 'romm' || source === 'store')
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
return status(200);
}
return status('Not Implemented');
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
if (validCommand)
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands)
{
if (validCommand instanceof Error)
if (validCommands instanceof Error)
{
return errorToResponse(validCommand, set);
return errorToResponse(validCommands, set);
}
else
{
try
{
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
launchCommand(validCommand.command, source, id, validCommands.gameId);
return { type: 'application', command: null };
} else
{
return status("Not Found");
}
} catch (error)
{
console.error(error);
@ -291,5 +378,27 @@ export default new Elysia()
}
}
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ command_id: z.number().or(z.string()).optional() }),
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
})
.post("/stop", async ({ }) =>
{
if (activeGame)
{
events.emit('activegameexit', {
source: 'local', id: String(activeGame.gameId),
exitCode: null,
signalCode: null
});
}
})
.get('/emulatorjs/data/cores/*', async ({ params }) =>
{
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
return res;
})
.get('/emulatorjs/data/*', async ({ params }) =>
{
return status("Not Found");
});

View file

@ -1,17 +1,18 @@
import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
import z from "zod";
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
import { count, eq, getTableColumns } from "drizzle-orm";
import { db } from "../app";
import { FrontEndPlatformType } from "@shared/constants";
import * as schema from "../schema/app";
import * as schema from "@schema/app";
import { CACHE_KEYS, getOrCached } from "../cache";
export default new Elysia()
.get('/platforms', async () =>
{
const platforms: FrontEndPlatformType[] = [];
let rommPlatformsSet: Set<string> | undefined;
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
.from(schema.platforms)
@ -32,7 +33,7 @@ export default new Elysia()
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
game_count: p.rom_count,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: p.id },
id: { source: 'romm', id: String(p.id) },
hasLocal: localPlatformSet.has(p.slug),
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
};
@ -46,7 +47,13 @@ export default new Elysia()
platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
{
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} });
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
let screenshots: { id: number; }[] = [];
if (game)
{
screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } });
}
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.name,
@ -54,9 +61,9 @@ export default new Elysia()
path_cover: `/api/romm/platform/local/${p.id}/cover`,
game_count: p.game_count,
updated_at: p.created_at,
id: { source: 'local', id: p.id },
id: { source: 'local', id: String(p.id) },
hasLocal: true,
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
};
@ -66,13 +73,52 @@ export default new Elysia()
return { platforms };
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
{
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
if (rommPlatform.data)
if (source === 'romm')
{
return rommPlatform.data;
const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } });
if (rommPlatform)
{
const platform: FrontEndPlatformType = {
slug: rommPlatform.slug,
name: rommPlatform.display_name,
family_name: rommPlatform.family_name,
path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`,
game_count: rommPlatform.rom_count,
updated_at: new Date(rommPlatform.updated_at),
id: { source: 'romm', id: String(rommPlatform.id) },
paths_screenshots: [],
hasLocal: false
};
return platform;
}
return status("Not Found", response);
}
else if (source === 'local')
{
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) });
if (localPlatform)
{
const platform: FrontEndPlatformType = {
slug: localPlatform.slug,
name: localPlatform.name,
family_name: localPlatform.family_name,
path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`,
game_count: 0,
updated_at: localPlatform.created_at,
id: { source: 'local', id: String(localPlatform.id) },
hasLocal: true,
paths_screenshots: []
};
return platform;
}
return status("Not Found");
}
return status("Not Found", rommPlatform.response);
return status("Not Implemented");
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
{
const coverBlob = await db.query.platforms.findFirst({

View file

@ -2,26 +2,19 @@ import path from 'node:path';
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 * as schema from '@schema/emulators';
import * as appSchema from "@schema/app";
import { eq } from 'drizzle-orm';
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
import { spawn } from 'node:child_process';
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
import { CommandEntry } from '@/shared/constants';
export const varRegex = /%([^%]+)%/g;
interface CommandEntry
{
label?: string;
command: string;
valid: boolean;
emulator?: string;
}
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
{
if (activeGame && activeGame.process?.killed === false)
{
@ -69,13 +62,12 @@ export async function launchCommand (validCommand: string, source: string, sourc
if (source === 'romm')
{
updateRommProps(sourceId);
updateRommProps(Number(sourceId));
}
else if (localGame?.source === 'romm' && localGame.source_id)
{
updateRommProps(localGame.source_id);
updateRommProps(Number(localGame.source_id));
}
});
/* Old spawn lanching, cases issues, needs to be ran as shell
@ -117,7 +109,10 @@ export async function getValidLaunchCommands (data: {
}): Promise<CommandEntry[]>
{
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, data.systemSlug)
});
if (!system)
{
@ -165,7 +160,7 @@ export async function getValidLaunchCommands (data: {
}
}
const formattedCommands = await Promise.all(system.commands.map(async command =>
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
{
const label = command.label;
let cmd = command.command;
@ -213,14 +208,14 @@ export async function getValidLaunchCommands (data: {
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExec(emulatorName);
let exec = await findExecByName(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = data.customEmulatorConfig.get(emulatorName);
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
}
emulator = emulatorName;
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
@ -237,6 +232,7 @@ export async function getValidLaunchCommands (data: {
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
@ -246,13 +242,18 @@ export async function getValidLaunchCommands (data: {
return formattedCommands.filter(c => !!c);
}
export async function findExec (emulatorName: string)
export async function findExecByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return findExec(emulator);
}
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
if (os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
@ -263,7 +264,7 @@ export async function findExec (emulatorName: string)
const registryValue = await readRegistryValue(node);
if (registryValue)
{
return registryValue;
return { path: registryValue, type: 'registry' };
}
}
@ -276,7 +277,7 @@ export async function findExec (emulatorName: string)
const systemPath = await resolveSystemPath(systempaths);
if (systemPath)
{
return systemPath;
return { path: systemPath, type: 'system' };
}
}
@ -286,7 +287,7 @@ export async function findExec (emulatorName: string)
const staticPath = await resolveStaticPath(staticPaths);
if (staticPath)
{
return staticPath;
return { path: staticPath, type: 'static' };
}
}
}

View file

@ -1,13 +1,16 @@
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
import * as schema from '../../schema/app';
import * as schema from '@schema/app';
import { eq } from "drizzle-orm";
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";
class CommandSearchError extends Error
{
@ -18,7 +21,7 @@ class CommandSearchError extends Error
}
}
export async function getLocalGame (source: string, id: number)
export async function getLocalGame (source: string, id: string)
{
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
.from(schema.games)
@ -33,7 +36,7 @@ export async function getLocalGame (source: string, id: number)
return undefined;
}
export async function getValidLaunchCommandsForGame (source: string, id: number)
export async function getValidLaunchCommandsForGame (source: string, id: string)
{
const localGame = await getLocalGame(source, id);
if (localGame)
@ -42,18 +45,28 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
{
if (localGame.path_fs)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
if (cores[localGame.platform_slug])
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'emulatorjs',
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
});
}
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id };
}
else
{
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
}
} catch (error)
{
@ -76,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
return undefined;
}
export default async function buildStatusResponse (source: string, id: number)
export default async function buildStatusResponse (source: string, id: string)
{
let cleanup: (() => void) | undefined;
let closed = false;
@ -87,6 +100,7 @@ export default async function buildStatusResponse (source: string, id: number)
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
{
if (closed) return;
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
}
@ -136,13 +150,14 @@ export default async function buildStatusResponse (source: string, id: number)
}
else
{
enqueue({ status: 'installed', details: validCommand.command.label });
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
}
} else if (source === 'romm')
}
else if (source === 'romm')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
@ -152,6 +167,20 @@ export default async function buildStatusResponse (source: string, id: number)
enqueue({ status: 'install', details: 'Install' });
}
} else if (source === 'store')
{
const storeGame = await getStoreGameFromId(id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath'));
if (size > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
}
}
}
}
@ -190,7 +219,7 @@ export default async function buildStatusResponse (source: string, id: number)
{
enqueue({
status: 'error',
error: error
error: getErrorMessage(error)
}, 'error');
}
}));

View file

@ -1,11 +1,12 @@
import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "../../app";
import { config, db, emulatorsDb } from "../../app";
import { and, eq } from "drizzle-orm";
import * as schema from "../../schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import * as schema from "@schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
import * as emulatorSchema from "@schema/emulators";
export async function calculateSize (installPath: string | null)
{
@ -19,15 +20,15 @@ export async function checkInstalled (installPath: string | null)
return fs.exists(path.join(config.get('downloadPath'), installPath));
}
export function getLocalGameMatch (id: number, source: string)
export function getLocalGameMatch (id: string, source: string)
{
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
}
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
{
const game: FrontEndGameType = {
id: { id: rom.id, source: 'romm' },
id: { id: String(rom.id), source: 'romm' },
path_cover: `/api/romm/image/romm${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),
@ -40,11 +41,131 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
source: null,
source_id: null,
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
platform_slug: rom.platform_slug
};
return game;
}
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null;
screenshotIds?: number[];
})
{
const game: FrontEndGameType = {
platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
path_fs: g.path_fs,
last_played: g.last_played,
slug: g.slug,
name: g.name,
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null
};
return game;
}
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null;
screenshotIds?: number[];
})
{
const game: FrontEndGameTypeDetailed = {
platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
path_fs: g.path_fs,
last_played: g.last_played,
slug: g.slug,
name: g.name,
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null,
summary: g.summary,
fs_size_bytes: 0,
missing: false,
local: true
};
return game;
}
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
{
let size: number | null = null;
try
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
});
const platformDef = await emulatorsDb.query.systems.findFirst({
where: eq(emulatorSchema.systems.name, system),
columns: { fullname: true }
});
const gameId = `${system}@${id}`;
const game: FrontEndGameType = {
platform_display_name: platformDef?.fullname ?? system,
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
id: { source: 'store', id: gameId },
source: null,
source_id: null,
path_fs: null,
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
last_played: null,
updated_at: new Date(),
slug: null,
name: storeGame.title,
platform_id: null,
platform_slug: system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
};
return game;
}
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
{
let size: number | null = null;
try
{
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
size = Number(fileResponse.headers.get('content-length'));
} catch (error)
{
console.error(error);
}
const detailed: FrontEndGameTypeDetailed = {
...await convertStoreToFrontend(system, id, storeGame),
summary: storeGame.description,
fs_size_bytes: size,
missing: false,
local: false,
};
return detailed;
}
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
{
const detailed: FrontEndGameTypeDetailed = {

View file

@ -2,13 +2,16 @@ import { IJob, JobContext } from "../task-queue";
import { mkdir } from 'node:fs/promises';
import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises';
import * as schema from "../schema/app";
import * as emulatorSchema from "../schema/emulators";
import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path from 'node:path';
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
import { config, db, emulatorsDb, jar } from "../app";
import unzip from 'unzip-stream';
import { Readable, Transform } from "node:stream";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
interface JobConfig
{
@ -18,15 +21,15 @@ interface JobConfig
export class InstallJob implements IJob
{
public id: number;
public gameId: string;
public source: string;
public sourceId: number;
public sourceId: string;
public config?: JobConfig;
static id = "install-job" as const;
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
{
this.id = id;
this.gameId = id;
this.config = config;
this.sourceId = sourceId;
this.source = source;
@ -41,6 +44,65 @@ export class InstallJob implements IJob
{
const downloadPath = config.get('downloadPath');
let downloadUrl: URL;
let cookie: string = '';
let screenshotUrls: string[];
let coverUrl: string;
let rommPlatform: PlatformSchema | undefined;
let slug: string | null;
let path_fs: string | undefined;
let summary: string | null;
let name: string | null;
let last_played: Date | null;
let igdb_id: number | null;
let ra_id: number | null;
let source_id: string;
let system_slug: string;
let extract_path: string;
switch (this.source)
{
case 'romm':
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
const rommAddress = config.get('rommAddress');
coverUrl = `${rommAddress}${rom.path_cover_large}`;
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
igdb_id = rom.igdb_id;
ra_id = rom.ra_id;
summary = rom.summary;
name = rom.name;
path_fs = path.join(rom.fs_path, rom.fs_name);
source_id = String(rom.id);
slug = rom.slug;
system_slug = rommPlatform.slug;
extract_path = '';
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
break;
case 'store':
const game = await getStoreGameFromId(this.gameId);
const gameId = extractStoreGameSourceId(this.gameId);
coverUrl = game.pictures.titlescreens[0];
screenshotUrls = game.pictures.screenshots;
downloadUrl = new URL(game.file);
slug = this.gameId;
source_id = this.gameId;
name = game.title;
summary = game.description;
system_slug = gameId.system;
extract_path = 'roms', gameId.system;
break;
default:
throw new Error("Unsupported source");
}
if (this.config?.dryDownload !== true)
{
/*
@ -92,11 +154,10 @@ export class InstallJob implements IJob
await fs.rm(zipFilePath);*/
cx.setProgress(0, 'download');
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
const res = await fetch(downloadUrl, {
headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
cookie: cookie
},
});
@ -119,62 +180,99 @@ export class InstallJob implements IJob
await new Promise((resolve, reject) =>
{
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject);
const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), });
(extract as any).unzipStream.on('entry', (entry: any) =>
{
if (!path_fs)
path_fs = path.join(extract_path, entry.path);
});
Readable.fromWeb(res.body as any).pipe(progressStream)
.pipe(extract)
.on('close', resolve)
.on('error', reject);
});
}
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
if (this.config?.dryDownload === true)
{
rom.files.length;
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
}
// pre-fetch screenshots
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
const rommAddress = config.get('rommAddress');
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
const coverResponse = await fetch(coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer());
if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [];
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
const platformSearch = [eq(schema.platforms.slug, system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)];
const esPlatform = await emulatorsDb
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
.from(emulatorSchema.systemMappings)
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
if (rommPlatform)
{
if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id));
if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug));
if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id));
if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id));
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.slug));
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;
if (!existingPlatform)
{
// Create new local platform
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
const platform: typeof schema.platforms.$inferInsert = {
slug: romPlatform.slug,
igdb_id: romPlatform.igdb_id,
igdb_slug: romPlatform.igdb_slug,
ra_id: romPlatform.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: romPlatform.name,
family_name: romPlatform.family_name,
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : undefined
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
// TODO: use something else than the romm demo as CDN
const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${system_slug}.svg`);
if (!esPlatform && !rommPlatform)
{
// go to unknown platform
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
platformId = existingPlatform.id;
} else
{
const [{ id }] = await tx.insert(schema.platforms).values({
slug: 'unknown',
name: "Unknown"
}).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
// Create new local platform
const platform: typeof schema.platforms.$inferInsert = {
slug: rommPlatform?.slug ?? esPlatform?.system.name ?? '',
igdb_id: rommPlatform?.igdb_id,
igdb_slug: rommPlatform?.igdb_slug,
ra_id: rommPlatform?.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '',
family_name: rommPlatform?.family_name,
es_slug: esPlatform?.system.name ?? undefined
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
platformId = existingPlatform.id;
@ -182,32 +280,52 @@ export class InstallJob implements IJob
// create the rom
const game: typeof schema.games.$inferInsert = {
source_id: rom.id,
source: 'romm',
slug: rom.slug,
path_fs: path.join(rom.fs_path, rom.fs_name),
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
source_id,
source: this.source,
slug,
path_fs,
last_played: last_played,
platform_id: platformId,
igdb_id: rom.igdb_id,
ra_id: rom.ra_id,
summary: rom.summary,
name: rom.name,
cover: Buffer.from(await coverResponse.arrayBuffer()),
igdb_id: igdb_id,
ra_id: ra_id,
summary: summary,
name,
cover,
cover_type: coverResponse.headers.get('content-type')
};
// Save screenshots and update database
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
if (screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
{
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', igdb_id)).execute();
screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
}
}
// pre-fetch screenshots
const screenshots = await Promise.all(screenshotUrls.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
});
}

80
src/bun/api/jobs/jobs.ts Normal file
View file

@ -0,0 +1,80 @@
import Elysia from "elysia";
import z, { } from "zod";
import { taskQueue } from "../app";
import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store";
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (job: T, path: Path, dataSchema: TS)
{
return new Elysia().ws(path, {
body: z.discriminatedUnion('type', [
z.object({ type: z.literal('cancel') })
]),
response: z.discriminatedUnion('type', [
z.object({
type: z.literal(['data', 'started', 'progress']),
status: z.string(),
progress: z.number(),
data: dataSchema
}),
z.object({ type: z.literal(['completed', 'ended']) }),
z.object({ type: z.literal('error'), error: z.unknown() })
]),
open (ws)
{
const job = taskQueue.findJob(path);
if (job)
{
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
}
(ws.data as any).cleanup = [
taskQueue.on('started', ({ id, job }) =>
{
if (id === path)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('progress', ({ id, job }) =>
{
if (id === path)
{
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
}
}),
taskQueue.on('completed', ({ id }) =>
{
if (id === path)
{
ws.send({ type: 'completed' });
}
}),
taskQueue.on('error', ({ id, error }) =>
{
if (id === path)
{
ws.send({ type: 'error', error: error });
}
})
];
},
close (ws)
{
(ws.data as any).cleanup.forEach((d: Function) => d());
},
message (ws, message)
{
if (message.type === 'cancel')
{
taskQueue.findJob(path)?.abort('cancel');
}
},
});
}
export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));

View file

@ -4,20 +4,27 @@ import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors";
import { tryLoginAndSave } from "../auth";
import z from "zod";
import { config } from "../app";
import z from "zod";
import { delay } from "@/shared/utils";
export class LoginJob implements IJob
{
endsAt: Date;
startedAt: Date;
url: string;
static id = "login-job" as const;
static dataSchema = z.object({ endsAt: z.date(), startedAt: z.date(), url: z.url() });
constructor()
{
this.endsAt = new Date();
this.endsAt = new Date(new Date().getTime() + 300000);
this.startedAt = new Date();
this.url = `http://${localIp}:${LOGIN_PORT}/`;
}
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
async start (context: JobContext): Promise<any>
{
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
@ -44,12 +51,7 @@ export class LoginJob implements IJob
try
{
loginServer.listen({});
await new Promise((resolve, reject) =>
{
this.endsAt = new Date(new Date().getTime() + 300000);
context.abortSignal.addEventListener('abort', () => reject());
setTimeout(() => { reject('timeout'); }, 300000); // auto close after 5 minutes
});
await delay(this.endsAt, context.abortSignal);
} catch
{
} finally

View file

@ -0,0 +1,110 @@
import { IJob, JobContext } from "../task-queue";
import secrets from "../secrets";
import open from "open";
import z from "zod";
import { delay } from "@/shared/utils";
interface TwitchDevice
{
device_code: string,
expires_in: number,
expires_at: Date,
started_at: Date,
interval: number,
user_code: string,
verification_uri: string;
}
export default class TwitchLoginJob implements IJob
{
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
device?: TwitchDevice;
clientId: string;
openInBrowser: boolean;
static id = 'twitch-login-job' as const;
static dataSchema = z.object({ expires_at: z.date(), started_at: z.date(), url: z.url(), user_code: z.string() }).or(z.undefined());
constructor(clientId: string, openInBrowser: boolean)
{
this.clientId = clientId;
this.openInBrowser = openInBrowser;
}
exposeData = (): z.infer<typeof TwitchLoginJob.dataSchema> => this.device ? ({
expires_at: this.device.expires_at,
started_at: this.device.started_at,
url: this.device.verification_uri,
user_code: this.device.user_code
}) : undefined;
async start (context: JobContext): Promise<any>
{
context.setProgress(0, "Retrieving Device");
let res = await fetch("https://id.twitch.tv/oauth2/device", {
method: "POST",
body: new URLSearchParams({
client_id: this.clientId,
scopes: this.twitchScopes
}),
signal: context.abortSignal
});
const device: TwitchDevice = await res.json();
const expiredTimeout = setTimeout(() => context.abort('expired'), device.expires_in * 1000);
device.expires_at = new Date(new Date().getTime() + device.expires_in * 1000);
device.started_at = new Date();
this.device = device;
try
{
if (this.openInBrowser)
open(device.verification_uri);
this.device = device;
context.setProgress(50, "Waiting For Authentication");
while (true)
{
if (context.abortSignal.aborted) break;
await delay(device.interval * 1000, context.abortSignal);
res = await fetch("https://id.twitch.tv/oauth2/token", {
method: "POST",
body: new URLSearchParams({
client_id: this.clientId,
scopes: this.twitchScopes,
device_code: this.device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
}),
signal: context.abortSignal
});
if (res.status === 200)
{
const data: {
access_token: string,
expires_in: number,
refresh_token: string,
scope: string[],
token_type: string;
} = await res.json();
secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
break;
}
else if (res.status !== 400)
{
console.error(res.statusText);
break;
}
}
} finally
{
clearTimeout(expiredTimeout);
}
}
}

View file

@ -0,0 +1,76 @@
import { ensureDir } from "fs-extra";
import { IJob, JobContext } from "../task-queue";
import { getStoreFolder } from "../store/store";
export default class UpdateStoreJob implements IJob
{
static id = "update-store" as const;
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
static branch = "master";
async gitCommand (commands: string[], dir: string)
{
const proc = Bun.spawn(['git', ...commands], {
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [output] = await Promise.all([
new Response(proc.stdout).text(),
proc.exited,
]);
return output.trim();
}
async isGitRepo (dir: string)
{
return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true';
}
async getOrigin (dir: string)
{
const origin = await this.gitCommand(["remote", "get-url", "origin"], dir);
return origin;
}
async hasChanges (dir: string)
{
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
}
async start (context: JobContext)
{
const storeFolder = getStoreFolder();
await ensureDir(storeFolder);
context.setProgress(10);
if (await this.isGitRepo(storeFolder))
{
const existingOrigin = await this.getOrigin(storeFolder);
if (existingOrigin !== UpdateStoreJob.origin)
{
throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`);
}
// check for uncommitted changes
const status = await this.gitCommand([" status", "--porcelain"], storeFolder);
if (status.length > 0)
{
console.log("Cleaning local changes...");
await this.gitCommand(["reset", "--hard"], storeFolder);
await this.gitCommand(["clean", "-fd"], storeFolder);
}
// fetch & reset to remote
await this.gitCommand(["fetch", "origin"], storeFolder);
await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder);
console.log("Shop Repo updated");
} else
{
context.setProgress(50);
await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder);
context.setProgress(100);
}
}
}

View file

@ -1,17 +1,21 @@
import { cors } from "@elysiajs/cors";
import Elysia from "elysia";
import { RPC_PORT } from "../../shared/constants";
import { RPC_PORT } from "@shared/constants";
import clients from "./clients";
import { settings } from "./settings";
import { settings } from "./settings/settings";
import { system } from "./system";
import { store } from "./store/store";
import { host } from "../utils/host";
import { jobs } from "./jobs/jobs";
const api = new Elysia({ serve: {} })
.use([cors(), clients, settings, system]);
.use([cors(), clients, settings, system, store, jobs]);
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 function RunAPIServer ()
{

View file

@ -3,7 +3,7 @@ import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
export const games = sqliteTable('games', {
id: integer('id').primaryKey({ autoIncrement: true }),
source_id: integer('source_id').unique(),
source_id: text('source_id'),
source: text("source"),
igdb_id: integer("igdb_id").unique(),
name: text("name"),

View file

@ -0,0 +1,10 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export default {
item_cache: sqliteTable('item_cache', {
key: text('key').primaryKey(),
data: text('data', { mode: 'json' }).notNull(),
expire_at: integer("expire_at", { mode: 'timestamp' }).notNull(),
updated_at: integer("updated_at", { mode: 'timestamp' }).notNull(),
})
};

View file

@ -29,6 +29,10 @@ export const systemMappings = sqliteTable('systemMappings', {
system: text().notNull().references(() => systems.name)
});
export const systemMappingsRelations = relations(systemMappings, ({ one }) => ({
system: one(systems, { fields: [systemMappings.system], references: [systems.name] })
}));
export const commands = sqliteTable('commands', {
system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }),
label: text(),
@ -36,7 +40,7 @@ export const commands = sqliteTable('commands', {
});
export const commandsRelations = relations(commands, ({ one }) => ({
author: one(systems, {
system: one(systems, {
fields: [commands.system],
references: [systems.name],
}),

View file

@ -1,150 +0,0 @@
import z from "zod";
import { LOGIN_PORT, SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia";
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
import * as appSchema from './schema/app';
import { findExec } from "./games/services/launchGameService";
import * as emulatorSchema from "./schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import fs from 'node:fs/promises';
import { existsSync } from "node:fs";
import { InstallJob } from "./jobs/install-job";
import { move } from "fs-extra";
export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () =>
{
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id })
.from(appSchema.games)
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
.groupBy(appSchema.platforms.es_slug);
const platformLookup = new Map(localGames.map(g => [g.es_slug, g.platform_id]));
const commands = await emulatorsDb
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
const emulatorCounts: Record<string, number> = {};
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
matches.forEach(m =>
{
emulatorCounts[m] = (emulatorCounts[m] ?? 0) + 1;
});
return matches?.map(m => [m, command.system_slug] as [string, string]);
}
).filter(c => !!c);
const uniqueEmulators = new Map(emulators);
return await Promise.all(Array.from(uniqueEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: string | undefined;
if (customEmulators.has(emulator))
{
execPath = customEmulators.get(emulator);
} else
{
execPath = await findExec(emulator);
}
let platform: number | null | undefined = null;
if (emulatorCounts[emulator] <= 1)
{
platform = platformLookup.get(system_slug);
}
return { emulator: emulator, path: execPath, exists: !!execPath && await fs.exists(execPath), path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null };
}));
}, {
response: z.array(z.object({ emulator: z.string(), path: z.string().optional(), exists: z.boolean(), path_cover: z.string().nullable() }))
})
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
{
return customEmulators.set(id, value);
},
{
body: z.object({ value: z.string() })
})
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.delete(id);
})
.get('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.get(id);
},
{
response: z.string()
})
.get('/emulators/custom', async () =>
{
return Object.keys(customEmulators.store);
}, {
response: z.array(z.string())
})
.put('/path/download', async ({ body: { manualPath, drive } }) =>
{
if (taskQueue.hasActiveOfType(InstallJob))
{
return status("Forbidden", "Installation in progress");
}
const oldDownloadPath = config.get('downloadPath');
if (!existsSync(oldDownloadPath))
{
return status("Not Found", "Old download path doesn't exist");
}
async function isDirEmpty (dirname: string)
{
const files = await fs.readdir(dirname);
return files.length === 0;
}
const path = manualPath ?? drive;
if (!path)
{
return;
}
if (existsSync(path) && !isDirEmpty(path))
{
return status("Conflict", "New location already exists and is not empty");
}
await move(oldDownloadPath, path);
config.set('downloadPath', manualPath);
return manualPath;
}, {
body: z.object({
manualPath: z.string().optional(),
drive: z.string().optional()
})
})
.get("/:id", async ({ params: { id } }) =>
{
const value = config.get(id);
return { value: value };
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
}).post('/:id',
async ({ params: { id }, body: { value }, }) =>
{
config.set(id, value);
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
body: z.object({ value: z.any() }),
});

View file

@ -0,0 +1,193 @@
import * as appSchema from '@schema/app';
import { findExec, findExecByName } from "../games/services/launchGameService";
import * as emulatorSchema from "@schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import { customEmulators, db, emulatorsDb } from '../app';
import fs from 'node:fs/promises';
import { cores } from '../emulatorjs/emulatorjs';
/**
* Get emulators based on local games. Only the ones we probably need.
* */
export async function getRelevantEmulators ()
{
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id, platform_name: appSchema.platforms.name })
.from(appSchema.games)
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
.groupBy(appSchema.platforms.es_slug);
const platformLookup = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, g]));
const platformViability = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, false]));
// check emulator js
for (const platform of platformLookup)
{
if (cores[platform[0]])
platformViability.set(platform[0], true);
}
// all commands based on the local games
const commands = await emulatorsDb
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
// get all emulators in said commands
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
return matches?.map(m => ({ emulator: m, system: command.system_slug }));
}
).filter(c => !!c);
// Group them by name
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: { path: string; type: string, } | undefined;
if (customEmulators.has(emulator))
{
execPath = { path: customEmulators.get(emulator), type: 'custom' };
} else
{
execPath = await findExecByName(emulator);
}
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
if (validSystemSlug?.system)
{
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
}
// check if automatic or custom path found existing binary.
// This might not be the actual emulator but I don't care.
const exists = !!execPath && await fs.exists(execPath.path);
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
if (exists)
{
systems.forEach(s => platformViability.set(s, true));
}
return {
emulator: emulator,
path: execPath,
exists: exists,
isCritical: false,
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
};
}));
finalEmulators.push({
emulator: 'emulatorjs',
exists: true,
path: { path: 'localhost', type: 'js' },
path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
isCritical: false,
systems: []
});
return finalEmulators.map(e =>
{
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
return e;
});
}
/**
* Only emulators we strictly need based on local games. Emulator JS is included as bundled.
* If there is even single emulator for a system don't include emulators for that system.
*/
/*export async function getMissingEmulators ()
{
const localGames = await db.query.games.findMany({
columns: {
platform_id: true,
slug: true
},
with: {
platform: {
columns: {
name: true,
es_slug: true
}
},
}
});
const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g]));
const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false]));
// all commands based on the local games
const commands = await emulatorsDb.query.commands.findMany({
columns: { command: true },
where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))),
with: { system: { columns: { name: true } } }
});
// get all emulators in said commands
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
return matches?.map(m => ({ emulator: m, system: command.system?.name }));
}
).filter(c => !!c);
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: { path: string; type: string, } | undefined;
if (customEmulators.has(emulator))
{
execPath = { path: customEmulators.get(emulator), type: 'custom' };
} else
{
execPath = await findExecByName(emulator);
}
let platform: number | null | undefined = null;
if (system_slug.length <= 1)
{
platform = platformLookup.get(system_slug[0].system)?.platform_id;
}
// check if automatic or custom path found existing binary.
// This might not be the actual emulator but I don't care.
const exists = !!execPath && await fs.exists(execPath.path);
const systems = Array.from(new Set(system_slug.map(s => s.system)));
if (exists)
{
systems.forEach(s => platformViability.set(s, true));
}
return {
emulator: emulator,
path: execPath,
exists: exists,
isCritical: false,
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
};
}));
return finalEmulators.map(e =>
{
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
return e;
});
}*/

View file

@ -0,0 +1,94 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia";
import { config, customEmulators, taskQueue } from "../app";
import fs from 'node:fs/promises';
import { existsSync } from "node:fs";
import { InstallJob } from "../jobs/install-job";
import { move } from "fs-extra";
import { getRelevantEmulators } from "./services";
export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () =>
{
return getRelevantEmulators();
})
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
{
return customEmulators.set(id, value);
},
{
body: z.object({ value: z.string() })
})
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.delete(id);
})
.get('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.get(id);
},
{
response: z.string()
})
.get('/emulators/custom', async () =>
{
return Object.keys(customEmulators.store);
}, {
response: z.array(z.string())
})
.put('/path/download', async ({ body: { manualPath, drive } }) =>
{
if (taskQueue.hasActiveOfType(InstallJob))
{
return status("Forbidden", "Installation in progress");
}
const oldDownloadPath = config.get('downloadPath');
if (!existsSync(oldDownloadPath))
{
return status("Not Found", "Old download path doesn't exist");
}
async function isDirEmpty (dirname: string)
{
const files = await fs.readdir(dirname);
return files.length === 0;
}
const path = manualPath ?? drive;
if (!path)
{
return;
}
if (existsSync(path) && !isDirEmpty(path))
{
return status("Conflict", "New location already exists and is not empty");
}
await move(oldDownloadPath, path);
config.set('downloadPath', manualPath);
return manualPath;
}, {
body: z.object({
manualPath: z.string().optional(),
drive: z.string().optional()
})
})
.get("/:id", async ({ params: { id } }) =>
{
const value = config.get(id);
return { value: value };
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
}).post('/:id',
async ({ params: { id }, body: { value }, }) =>
{
config.set(id, value);
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
body: z.object({ value: z.any() }),
});

View file

@ -0,0 +1,59 @@
import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
import { CACHE_KEYS, getOrCached } from "../../cache";
export async function getStoreGameManifest ()
{
return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () =>
{
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data));
return store.tree.filter((e: any) =>
{
if (e.type === 'blob' && e.path !== "featured.json")
{
return true;
}
return false;
});
});
}
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{
const offset = filter?.offset ?? 0;
const limit = Math.min(50, filter?.limit ?? 10);
const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) =>
{
return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, "")))));
}));
return games;
}
export function extractStoreGameSourceId (id: string)
{
const gameId = id.split('@');
if (gameId.length !== 2)
throw new Error("Store ID should include platform and name with @ separator");
return { system: gameId[0], id: gameId[1] };
}
export function getStoreGameFromId (id: string)
{
const data = extractStoreGameSourceId(id);
return getStoreGame(data.system, data.id);
}
export async function getStoreGame (system: string, id: string)
{
return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`);
}
export async function getStoreGameFromPath (path: string)
{
const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`)
.then(e => e.json())
.then(g => StoreGameSchema.parseAsync(g)));
return game;
}

201
src/bun/api/store/store.ts Normal file
View file

@ -0,0 +1,201 @@
import Elysia from "elysia";
import { config, customEmulators, db } from "../app";
import path from "node:path";
import fs from 'node:fs/promises';
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
import { findExec } from "../games/services/launchGameService";
import { emulatorsDb } from '../app';
import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators';
import * as appSchema from '@schema/app';
import z from "zod";
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
import { CACHE_KEYS, getOrCached } from "../cache";
export function getStoreFolder ()
{
const downlodDir = config.get('downloadPath');
return path.join(downlodDir, "store");
}
async function getAllStoreEmulatorPackages ()
{
const downlodDir = config.get('downloadPath');
const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators");
const emulators = await fs.readdir(emulatorsBucket);
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
{
if (e.error)
{
console.error(e.error);
}
return e.data;
}).map(e => e.data!);
return emulatesParsed;
}
async function buildSystems (emulator: EmulatorPackageType)
{
const systems = await Promise.all(emulator.systems.map(async system =>
{
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
});
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
return { id: system, name: esSystem?.fullname ?? system, icon: icon };
}));
return systems;
}
export const store = new Elysia({ prefix: '/api/store' })
.get('/emulators', async ({ query }) =>
{
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e =>
{
console.error(e);
return undefined;
});
const emulatesParsed = await getAllStoreEmulatorPackages();
let frontEndEmulators = await Promise.all(emulatesParsed
.filter(e => e.os.includes(process.platform as any))
.map(async (emulator) =>
{
let execPath: { path: string; type: string; } | undefined;
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
if (esEmulator)
{
if (customEmulators.has(emulator?.name))
{
execPath = { path: customEmulators.get(emulator.name), type: 'custom' };
} else
{
execPath = await findExec(esEmulator);
}
}
const exists = !!execPath && await fs.exists(execPath.path);
const systems = await buildSystems(emulator);
const gameCounts = await Promise.all(systems.map(async (s) =>
{
const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) });
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
if (romPlatform)
{
return romPlatform.rom_count;
}
return 0;
}));
const gameCount = gameCounts.reduce((a, c) => a + c);
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
}));
if (query.missing)
{
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
}
if (query.orderBy === 'importance')
{
frontEndEmulators.sort((a, b) =>
{
const gameCountDiff = b.gameCount - a.gameCount;
if (gameCountDiff !== 0) return gameCountDiff;
return a.name.localeCompare(b.name);
});
}
if (query.limit)
{
frontEndEmulators = frontEndEmulators.splice(0, query.limit);
}
return frontEndEmulators;
},
{
query: z.object({
limit: z.coerce.number().optional(),
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional()
})
})
.get('/games/featured', async () =>
{
const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json');
const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json());
return Promise.all(games.featured.map(async g =>
{
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') });
if (localGame) return convertLocalToFrontendDetailed(localGame);
return convertStoreToFrontendDetailed(g.system, g.title, g);
}));
})
.get('/stats', async () =>
{
const emulatesParsed = await getAllStoreEmulatorPackages();
const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length;
const gameCount = await db.$count(appSchema.games);
return {
storeEmulatorCount,
gameCount
};
})
.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));
},
{ params: z.object({ id: z.string(), name: z.string() }) })
.get('/details/emulator/:id', async ({ params: { id } }) =>
{
const downlodDir = config.get('downloadPath');
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
const systems = await buildSystems(emulatorPackage);
let execPath: { path: string; type: string; } | undefined;
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) });
if (esEmulator)
{
if (customEmulators.has(emulatorPackage?.name))
{
execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' };
} else
{
execPath = await findExec(esEmulator);
}
}
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
const exists = !!execPath && await fs.exists(execPath.path);
const emulator: FrontEndEmulatorDetailed = {
...emulatorPackage,
systems,
exists,
status: {
source: execPath?.type,
location: execPath?.path
},
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`)
};
return emulator;
}, { params: z.object({ id: z.string() }) });

View file

@ -2,7 +2,7 @@ import Elysia from "elysia";
import open from 'open';
import z from "zod";
import os from 'node:os';
import { config, events } from "./app";
import { cachePath, config, events } from "./app";
import { isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
@ -11,6 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size";
import si from 'systeminformation';
import { getStoreFolder } from "./store/store";
export const system = new Elysia({ prefix: '/api/system' })
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
@ -48,7 +49,9 @@ export const system = new Elysia({ prefix: '/api/system' })
hostname: os.hostname(),
steamDeck: process.env.SteamDeck,
machine: os.machine(),
source
source,
cacheSize: (await fs.stat(cachePath)).size,
storeSize: (await getFolderSize(getStoreFolder())).size
};
})
.get('/notifications', ({ set }) =>

View file

@ -32,6 +32,7 @@ export class TaskQueue
setTimeout(this.processQueue);
});
return promise;
}
return Promise.resolve();
}
@ -91,8 +92,10 @@ export class TaskQueue
export interface EventsList
{
started: [e: BaseEvent];
progress: [e: ProgressEvent];
abort: [e: AbortEvent];
/** Called when the job successfully completes */
completed: [e: CompletedEvent];
error: [e: ErrorEvent];
ended: [e: BaseEvent];
@ -101,7 +104,7 @@ export interface EventsList
interface BaseEvent
{
id: string;
job: IJob;
job: IPublicJob;
}
interface ErrorEvent extends BaseEvent
@ -128,6 +131,7 @@ interface CompletedEvent extends BaseEvent
export interface IJob
{
start (context: JobContext): Promise<any>;
exposeData?(): any;
}
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
@ -137,7 +141,7 @@ export interface IPublicJob
progress: number;
state?: string;
status: JobStatus;
job: any;
job: IJob;
abort: (reason?: any) => void;
}
@ -152,7 +156,7 @@ export class JobContext implements IPublicJob
private error?: any;
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private m_job: IJob;
private readonly m_job: IJob;
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
{
@ -162,7 +166,7 @@ export class JobContext implements IPublicJob
this.abortController.signal.addEventListener('abort', () =>
{
this.aborted = true;
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this.m_job } satisfies AbortEvent);
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent);
});
this.events = events;
}
@ -171,19 +175,24 @@ export class JobContext implements IPublicJob
{
try
{
this.events.emit('started', { id: this.m_id, job: this });
await this.m_job.start(this);
this.completed = true;
this.events.emit('completed', { id: this.m_id, job: this.m_job });
this.events.emit('completed', { id: this.m_id, job: this });
} catch (error)
{
console.error(error);
this.events.emit('error', { id: this.m_id, job: this.m_job, error });
if (error !== 'cancel')
{
console.error(error);
}
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
} finally
{
this.running = false;
this.events.emit('ended', { id: this.m_id, job: this.m_job });
this.events.emit('ended', { id: this.m_id, job: this });
}
}
@ -211,7 +220,7 @@ export class JobContext implements IPublicJob
this.m_progress = progress;
if (state)
this.m_state = state;
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this.m_job });
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this });
}
public abort (reason?: any)

View file

@ -1,32 +1,42 @@
import { killBrowser, spawnBrowser } from './utils/browser-spawner';
import { BuildParams } from './utils/browser-params';
import { BrowserParams, BuildParams } from './utils/browser-params';
import os from 'node:os';
import { EventEmitter } from 'node:stream';
import { config } from './api/app';
import { dirname } from 'node:path';
export default async function init (events: EventEmitter, forceBrowser: boolean)
export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams)
{
if (forceBrowser)
{
await runBrowser(events);
await runBrowser(events, params);
} else
{
try
{
await runWebview(events);
await runWebview(events, params);
} catch (error)
{
await runBrowser(events);
await runBrowser(events, params);
}
}
}
async function runWebview (events: EventEmitter)
async function runWebview (events: EventEmitter, params: BrowserParams)
{
const webviewWorker = new Worker(new URL(`./webview/${os.platform()}`, import.meta.url).href, {
const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href;
console.log("Launching Webview Worker at: ", webviewPath);
const config: Record<string, string> = {};
if (params.windowSize)
{
config.WINDOW_WIDTH = String(params.windowSize?.width);
config.WINDOW_HEIGHT = String(params.windowSize?.height);
}
const webviewWorker = new Worker(webviewPath, {
smol: true,
ref: false
ref: false,
env: {
...config,
...process.env as any
}
});
return new Promise((resolve, reject) =>
@ -39,8 +49,9 @@ async function runWebview (events: EventEmitter)
webviewWorker.addEventListener('message', (e) =>
{
if (e.data === 'destroyed')
if (e.data.data === 'destroyed')
{
console.log("Webview Destroyed");
resolve(true);
}
});
@ -48,14 +59,15 @@ async function runWebview (events: EventEmitter)
events.on('exitapp', () =>
{
resolve(true);
console.log("Terminating Webview Worker");
webviewWorker.terminate();
});
});
}
async function runBrowser (events: EventEmitter)
async function runBrowser (events: EventEmitter, params: BrowserParams)
{
const browserParams = await BuildParams({ configPath: dirname(config.path) });
const browserParams = await BuildParams(params);
if (!browserParams)
{
console.error("Could not find valid browser");
@ -72,7 +84,7 @@ async function runBrowser (events: EventEmitter)
detached: false,
execPath: browserParams.browser.path,
source: browserParams.browser.source,
configPath: dirname(config.path),
configPath: params.configPath,
ipc (message)
{
console.log(message);

View file

@ -1,10 +1,12 @@
import { RunBunServer } from './server';
import { RunAPIServer } from './api/rpc';
import { cleanup as appCleanup, events } from './api/app';
import { cleanup as appCleanup, config, events } from './api/app';
import init from './browser';
import { dirname } from 'pathe';
import { createInterface } from 'readline';
const api = RunAPIServer();
let bunServer: { stop: () => void; url: URL; } | undefined;
let bunServer: { stop: () => void; } | undefined;
if (!Bun.env.PUBLIC_ACCESS)
{
@ -16,21 +18,25 @@ async function cleanup ()
console.log("Cleaning Up");
await appCleanup();
bunServer?.stop();
await api.apiServer.stop();
await api.apiServer.stop(true);
await api.cleanup();
console.log("Finished Cleaning Up");
process.exit(0);
}
if (Bun.env.HEADLESS)
{
// Called by outside force
process.on('message', ({ type }) =>
const rl = createInterface({ input: process.stdin });
rl.on("line", async (line) =>
{
if (type === 'exitapp')
if (line.trim() === "shutdown")
{
cleanup();
console.log("Graceful Shutdown");
await cleanup();
}
});
// Called by user
events.on('exitapp', () =>
{
@ -39,7 +45,11 @@ if (Bun.env.HEADLESS)
});
} else
{
await init(events, !!Bun.env.FORCE_BROWSER);
await init(events, Bun.env.FORCE_BROWSER === "true", {
configPath: dirname(config.path),
windowPosition: config.get('windowPosition'),
windowSize: config.get('windowSize')
});
await cleanup();
}

View file

@ -1,19 +1,44 @@
import { SERVER_PORT } from "../shared/constants";
import { SERVER_PORT } from "@shared/constants";
import path from 'node:path';
import appInfo from '../../package.json';
import appInfo from '~/package.json';
import { host } from "./utils/host";
import { appPath } from "./utils";
import Elysia, { file } from "elysia";
import cors from "@elysiajs/cors";
import staticPlugin from "@elysiajs/static";
export function RunBunServer ()
{
console.log("Launching Server on port ", SERVER_PORT);
return Bun.serve({
return new Elysia()
.use(cors())
.get("/", ({ set }) =>
{
set.headers['cross-origin-opener-policy'] = 'same-origin';
set.headers['cross-origin-embedder-policy'] = 'require-corp';
return file("./dist/index.html");
})
.get('/emulatorjs', ({ set }) =>
{
set.headers['cross-origin-opener-policy'] = 'same-origin';
set.headers['cross-origin-embedder-policy'] = 'require-corp';
set.headers['cross-origin-resource-policy'] = 'cross-origin';
return file('./dist/emulatorjs/index.html');
})
.use(staticPlugin({
indexHTML: false,
assets: "dist",
prefix: "/",
alwaysStatic: true
})).listen({ port: SERVER_PORT, hostname: host }, console.log);
/*return Bun.serve({
port: SERVER_PORT,
hostname: host,
routes: {
"/": Bun.file(appPath("./dist/index.html")),
// Serve a file by lazily loading it into memory
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
"/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")),
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
JSON.stringify({
name: appInfo.name,
@ -33,5 +58,5 @@ export function RunBunServer ()
const url = new URL(req.url);
return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`)));
},
});
});*/
}

View file

@ -7,4 +7,30 @@ export type ActiveGame = {
gameId: number;
name: string;
command: string;
};
};
interface ObjectConstructor
{
/**
* Groups members of an iterable according to the return value of the passed callback.
* @param items An iterable.
* @param keySelector A callback which will be invoked for each item in items.
*/
groupBy<K extends PropertyKey, T> (
items: Iterable<T>,
keySelector: (item: T, index: number) => K,
): Partial<Record<K, T[]>>;
}
interface MapConstructor
{
/**
* Groups members of an iterable according to the return value of the passed callback.
* @param items An iterable.
* @param keySelector A callback which will be invoked for each item in items.
*/
groupBy<K, T> (
items: Iterable<T>,
keySelector: (item: T, index: number) => K,
): Map<K, T[]>;
}

View file

@ -1,4 +1,3 @@
import { $ } from 'bun';
import path from 'node:path';

View file

@ -1,13 +1,19 @@
import { SERVER_URL } from "../../shared/constants";
import { SERVER_URL } from "@shared/constants";
import os from 'node:os';
import path from 'node:path';
import { getBrowserPath } from "./get-browser";
import { isSteamDeckGameMode } from "../utils";
import { config } from "../api/app";
import { ensureDir } from 'fs-extra';
import { host } from "./host";
export async function BuildParams (data: { configPath: string; })
export interface BrowserParams
{
configPath: string;
windowPosition?: { x: number, y: number; };
windowSize?: { width?: number, height?: number; };
}
export async function BuildParams (data: BrowserParams)
{
const validBrowser = await getBrowserPath({
browserOrder: Bun.env.BROWSER_PRIORITY ? Bun.env.BROWSER_PRIORITY.split(',') as any : ['chrome', 'chromium']
@ -52,9 +58,9 @@ export async function BuildParams (data: { configPath: string; })
if (isSteamDeckGameMode())
{
args.push('--kiosk');
} else
} else if (data.windowSize)
{
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
args.push(`--window-size=${data.windowSize.width},${data.windowSize.height}`);
}
args.push('--password-store=basic');
@ -71,9 +77,9 @@ export async function BuildParams (data: { configPath: string; })
args.push('--remote-debugging-port=9222');
}
if (config.has('windowPosition'))
if (data.windowPosition)
{
args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`);
args.push(`--window-position=${data.windowPosition.x},${data.windowPosition.y}`);
}
if (isEdge)

View file

@ -1,4 +1,4 @@
import { SERVER_URL } from "@/shared/constants";
import { SERVER_URL } from "@shared/constants";
import { host } from "../utils/host";
export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; })
@ -14,4 +14,5 @@ export default function (webview: { navigate: (url: string) => void; run: () =>
};
webview.navigate(SERVER_URL(host));
webview.run();
postMessage({ data: 'destroyed' });
}

View file

@ -1,4 +1,4 @@
import { Webview } from 'webview-bun';
import { Size, SizeHint, Webview } from 'webview-bun';
import webviewWorkerBase from "./base";
if (process.env.FLATPAK_BUILD === "true")
@ -28,6 +28,9 @@ if (process.env.FLATPAK_BUILD === "true")
} else
{
console.log("Launching Webview");
const webview = new Webview(import.meta.env.NODE_ENV === 'development');
let size: Size | undefined = undefined;
if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT)
size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE };
const webview = new Webview(process.env.NODE_ENV === 'development', size);
webviewWorkerBase(webview);
}

View file

@ -1,6 +1,9 @@
import { Webview } from 'webview-bun';
import { Size, SizeHint, Webview } from 'webview-bun';
import webviewWorkerBase from "./base";
const webview = new Webview(import.meta.env.NODE_ENV === 'development');
let size: Size | undefined = undefined;
if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT)
size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE };
const webview = new Webview(process.env.NODE_ENV === 'development', size);
webviewWorkerBase(webview);

View file

@ -1,11 +1,10 @@
import classNames from 'classnames';
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts';
import { useLocalSetting } from '../scripts/utils';
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
import { mobileCheck, useLocalSetting } from '../scripts/utils';
import { AnimatedBackgroundContext } from '../scripts/contexts';
export function AnimatedBackground (data: {
children?: any;
@ -15,26 +14,43 @@ export function AnimatedBackground (data: {
className?: string;
animated?: boolean,
scrolling?: boolean;
style?: CSSProperties;
})
{
const animateBackground = true;
const animateBackground = useLocalSetting('backgroundAnimation');
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ?
useSessionStorage<string | undefined>(
data.backgroundKey,
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
)
: useState<string | undefined>();
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
data.backgroundKey!,
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
) : useState<string | undefined>();
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
const backgroundElementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
const lastBg = backgroundUrl;
if (data.backgroundUrl != backgroundUrl)
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
setLastBackgroundUrl(lastBg);
}
}, [data.backgroundUrl]);
let finalBackgroundUrl;
let finalBackgroundUrl: URL | undefined;
try
{
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
} catch { }
let finalLastBackgroundUrl: URL | undefined;
try
{
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
} catch { }
const blur = useLocalSetting('backgroundBlur');
if (blur)
{
@ -43,11 +59,41 @@ export function AnimatedBackground (data: {
finalBackgroundUrl?.searchParams.set('blur', String(24));
}
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
{
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
}
finalBackgroundUrl?.searchParams.set('height', String(320));
finalLastBackgroundUrl?.searchParams.set('height', String(320));
}
useEffect(() =>
{
if (finalBackgroundUrl && backgroundElementRef.current)
{
const finalBackgroundImg = new Image();
finalBackgroundImg.addEventListener('load', e =>
{
if (backgroundElementRef.current)
{
backgroundElementRef.current.style.backgroundImage = `url('${finalBackgroundUrl.href}')`;
backgroundElementRef.current.style.opacity = "1";
backgroundElementRef.current.style.backgroundSize = "100%";
}
});
finalBackgroundImg.src = finalBackgroundUrl.href;
}
}, [finalBackgroundUrl]);
const isMobile = mobileCheck();
function handleSetBackground (url: string)
{
setLastBackgroundUrl(backgroundUrl);
setBackgroundUrl(url);
}
@ -70,30 +116,40 @@ export function AnimatedBackground (data: {
return (
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
<div ref={data.ref}
className={twMerge("w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
style={data.scrolling ? {
backgroundImage: `url('${finalBackgroundUrl?.href}')`,
backgroundAttachment: 'local',
backgroundSize: '100%',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-100)",
} : {}}
style={data.style}
className={twMerge("relative w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
>
{!data.scrolling && <div className='absolute top-0 left-0 overflow-hidden w-full h-full'>
{<img
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
<div className='fixed bg-base-100 top-0 left-0 right-0 bottom-0 -z-5'></div>
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4' src={finalLastBackgroundUrl.href}></img>}
{finalBackgroundUrl ? <img
key={finalBackgroundUrl?.href}
className={classNames('absolute w-full h-full object-cover object-center opacity-0 -z-3')}
className={'absolute w-full h-full object-cover object-center opacity-0 -z-3'}
src={finalBackgroundUrl?.href}
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
></img>}
<div className='absolute w-full h-full bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
></img> : <><div className='mobile:hidden bg-gradient'></div></>}
<div className='absolute top-0 left-0 right-0 bottom-0 bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
<div className='mobile:hidden bg-noise'></div>
</div>}
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
{data.animated && animateBackground && <div className="fixed overflow-hidden top-0 left-0 right-0 bottom-0" style={{ zIndex: -1 }}>
{backgroundElements}
</div>}
{data.children}
{!!data.scrolling && <>
<div key={finalBackgroundUrl?.href} ref={backgroundElementRef} className='absolute top-0 bottom-0 left-0 right-0' style={data.scrolling ? {
backgroundAttachment: 'local',
backgroundSize: '105%',
opacity: 0,
transition: 'all ease-out',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
transitionDuration: "400ms",
backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-300)",
} : {}}></div>
<div className='mobile:hidden bg-noise opacity-30 z-1'></div>
</>}
</div>
</AnimatedBackgroundContext >
);

View file

@ -1,13 +1,18 @@
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useEffect } from "react";
export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; })
export function AutoFocus (data: {
parentKey?: string;
focus: () => void;
force?: boolean;
delay?: number;
})
{
useEffect(() =>
{
let delayTimeout: number | undefined;
if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey()))
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
{
if (data.delay)
{

View file

@ -32,11 +32,10 @@ export interface GameCardParams
className?: string;
onFocus?: GameCardFocusHandler;
onBlur?: (id: string) => void;
onAction?: () => void;
clickFocuses?: boolean;
}
export default function GameCard (data: GameCardParams)
export default function CardElement (data: GameCardParams & InteractParams)
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
@ -57,40 +56,35 @@ export default function GameCard (data: GameCardParams)
scrollSnapAlign: "center"
}}
onFocus={focusSelf}
onDoubleClick={data.onAction}
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
onClick={() =>
{
focusSelf();
data.onAction?.();
}}
className={twMerge(
`game-card bg-base-300 game-card-height flex flex-col justify-end z-5 ring-primary`,
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
classNames({
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isMouse,
"h-(--game-card-height)": typeof data.preview === "string"
}),
"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",
data.className
)}
>
<div className={twMerge(
"overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all",
<div id="preview" className={twMerge(
"overflow-hidden bg-base-400 rounded-t-xl rounded-b-md transition-all",
focused ? "sm:mt-1 sm:mx-1" : "sm:mt-1 sm:mx-1",
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
classNames({ "h-full": typeof data.preview === "string" })
)}>
{typeof data.preview === "string" ? (
<img className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
)}
</div>
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2">
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
{data.badges?.map((b, i) =>
<div key={i}
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 sm:last:mr-1 md:last:mr-4 transition-colors",
twMerge("bg-base-100 text-base-content not-mobile:not-in-focused:drop-shadow-lg sm:border-3 md:border-6 border-base-300 in-focused:border-base-content overflow-hidden rounded-full sm:last:mr-1 md:last:mr-4 transition-colors",
classNames({
"bg-primary text-primary-content": focused && !isPointer,
"group-hover:bg-primary group-hover:text-primary-content": isPointer
@ -100,7 +94,7 @@ export default function GameCard (data: GameCardParams)
</div>)
}
</div>
<div className="flex flex-col sm:p-2 md:p-4">
<div className="flex flex-col sm:p-2 grow md:p-4 justify-center">
<div className="md:text-xl sm:text-sm font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title}
</div>

View file

@ -1,11 +1,10 @@
import
{
FocusContext,
FocusDetails,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardFocusHandler, GameCardParams } from "./GameCard";
import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
@ -47,7 +46,7 @@ export function CardList (data: {
useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
return (
<GameCard
<CardElement
key={g.id}
type={data.type}
index={i}
@ -74,9 +73,9 @@ export function CardList (data: {
id={`card-list-${data.id}`}
ref={ref}
save-child-focus="session"
className={twMerge("items-center justify-center-safe landscape:h-(--game-card-height) ",
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-(--game-card-height) grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
'landscape:flex sm:gap-2 md:gap-6 portrait:grid portrait:auto-rows-(--game-card-height) portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))]',
className={twMerge("items-center justify-center-safe h-full",
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!',
data.className
)}
onKeyDown={(e) =>

View file

@ -1,15 +1,19 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
export default function Clock() {
export default function Clock ()
{
const locale = "en";
const [today, setDate] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
useEffect(() =>
{
const timer = setInterval(() =>
{
setDate(new Date());
}, 60 * 1000);
return () => {
return () =>
{
clearInterval(timer);
};
}, []);

View file

@ -4,13 +4,15 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation";
import { GameCardFocusHandler } from "./GameCard";
import { GameCardFocusHandler } from "./CardElement";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
export default function CollectionList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: GameCardFocusHandler;
onSelect?: (id: string) => void;
})
{
const navigate = useNavigate();
@ -20,6 +22,12 @@ export default function CollectionList (data: {
staleTime: DefaultRommStaleTime
});
const handleDefaultSelect = (id: string) =>
{
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
};
return (
<CardList
type="collection"
@ -38,11 +46,7 @@ export default function CollectionList (data: {
</span>
],
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
SaveSource('game-list');
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
onGameFocus={(id, node, details) =>
{
data.setBackground(

View file

@ -1,16 +1,16 @@
import { AnimatedBackground } from './AnimatedBackground';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderUI } from './Header';
import { GameList, GameListFilter } from './GameList';
import { GameList } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './GameCard';
import { GameCardFocusHandler } from './CardElement';
export interface CollectionsDetailParams
{
@ -22,16 +22,6 @@ export interface CollectionsDetailParams
footer?: JSX.Element;
}
function HandleGoBack ()
{
const source = PopSource('game-list');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
@ -40,7 +30,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`,
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
const { shortcuts } = useShortcutContext();
const handleScroll: GameCardFocusHandler = (id, node, details) =>

View file

@ -1,14 +1,10 @@
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect } from "react";
import { JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { X } from "lucide-react";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
import { ContextDialogContext } from "../scripts/contexts";
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
{
@ -35,12 +31,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
trackChildren: typeof data.content !== 'string'
});
const colors = {
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
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"
};
if (data.shortcuts)
{
@ -51,8 +47,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
<FocusContext value={focusKey}>
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
classNames({ "font-semibold": focused || hasFocusedChild }),
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2 active:animate-scale in-focused:font-semibold",
data.className,
colors[data.type])}>
{data.icon}
@ -105,7 +100,7 @@ export function ContextDialog (data: {
}] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
}
onClick={() =>

View file

@ -0,0 +1,33 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { ErrorComponentProps } from "@tanstack/react-router";
import { mobileCheck } from "../scripts/utils";
export default function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
<TriangleAlert className="size-12" />
{data.error.message}
</p>
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
</FocusContext>
</div>;
}

View file

@ -1,11 +1,11 @@
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { createContext, useContext, useRef, useState } from "react";
import { useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType, Drive } from "@/shared/constants";
import { DirType } from "@/shared/constants";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
@ -13,17 +13,8 @@ import SvgIcon from "./SvgIcon";
import { Button } from "./options/Button";
import toast from "react-hot-toast";
import { drivesQuery, filesQuery } from "../scripts/queries";
const FilePickerContext = createContext<{
allowNewFolderCreation: boolean;
isDirectoryPicker: boolean;
setCurrentPath: (path: string) => void;
currentPath: string | undefined,
startingPath: string | undefined;
refetchFiles: () => void;
drives: Drive[],
activeDrive: Drive | undefined;
}>({} as any);
import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads";
function List (data: {
id: string,
@ -137,7 +128,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
});
return <div className="flex gap-2 grow -ml-2">
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
<Button id={`${data.id}-create`} onAction={e => createMutation.mutate()} type="button" ><FolderPlus /></Button>
</div>;
}
@ -149,7 +140,7 @@ function OptionButtons (data: {
})
{
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
return <div ref={ref} className="flex md:inline h-12 w-full justify-end gap-2">
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
<FocusContext value={focusKey}>
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
<Button className="md:p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
@ -252,6 +243,8 @@ export default function FilePicker (data: {
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
const { isPointer } = useActiveControl();
return <div className="flex flex-col h-full max-h-full gap-3">
<FilePickerContext value={{
setCurrentPath,
@ -271,8 +264,9 @@ export default function FilePicker (data: {
setCurrentPath(path.join(...fullPath.slice(-i)))
}>{p}</a>
</li>)}
{(filesLoading || drivesLoading) && <li className="mr-2 loading loading-spinner sm:loading-md md:loading-sm"></li>}
</ul>
{(filesLoading || drivesLoading) && <span className="loading loading-spinner sm:loading-md md:loading-lg"></span>}
</div>}
<ListWithDrives
@ -281,11 +275,11 @@ export default function FilePicker (data: {
onSelect={data.onSelect}
parentPath={files?.parentPath ?? ''}
/>
<OptionButtons
{isPointer && <OptionButtons
showConfirm={!!data.isDirectoryPicker}
onCancel={data.cancel}
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
id={data.id} />
id={data.id} />}
</FilePickerContext>
</div>;
}

View file

@ -4,10 +4,7 @@ import
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import classNames from "classnames";
import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function FilterCat (
data: {
@ -25,31 +22,12 @@ function FilterCat (
onEnterPress: data.onAction
});
const { filter } = useSearch({ from: '/' });
useEffect(() =>
{
if (filter == data.id && data.hasFocusedPeer)
{
focusSelf();
}
}, [filter]);
const { isMouse } = useActiveControl();
return (
<li
aria-selected={data.active}
ref={ref}
onClick={focusSelf}
className={classNames(
"sm:text-sm sm:px-2",
"flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg",
{
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
focused || data.active,
"ring-primary ring-7": focused && !isMouse,
"hover:bg-base-content/40 cursor-pointer": !focused,
},
)}
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
>
{data.children ?? data.label}
</li>
@ -59,35 +37,37 @@ function FilterCat (
export function FilterUI (data: {
id: string;
options: Record<string, FilterOption>;
selected: string;
setSelected: (id: string) => void;
containerClassName?: string;
className?: string;
})
{
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: `filter-${data.id}`,
focusKey: data.id,
saveLastFocusedChild: false,
autoRestoreFocus: false,
preferredChildFocusKey: data.selected,
preferredChildFocusKey: `${data.id}-${defaultFocus}`,
trackChildren: true
});
return (
<div
ref={ref}
save-child-focus="session"
className={data.containerClassName}
>
<FocusContext.Provider value={focusKey}>
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:h-9 md:h-14">
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
<li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
hasFocusedPeer={hasFocusedChild}
id={id}
id={`${data.id}-${id}`}
key={id}
onFocus={() => data.setSelected(id)}
active={id === data.selected}
active={option.selected}
{...option}
/>
))}

View file

@ -0,0 +1,21 @@
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { useGlobalFocus } from "../scripts/spatialNavigation";
export default function FocusDots (data: {
elements: string[];
})
{
const focusedKey = useGlobalFocus();
return <div className="divider opacity-20"><div className="flex gap-2 py-6 justify-center items-center h-3">{data.elements.map((em, i) =>
{
const focused = em === focusedKey;
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
})}</div></div>;
}

View file

@ -0,0 +1,46 @@
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
import CardElement from "./CardElement";
import { SaveSource } from "../scripts/spatialNavigation";
import { Router } from "..";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
import { FOCUS_KEYS } from "../scripts/types";
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
{
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
};
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
const subtitle = <div className="flex gap-1 items-center">
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
<p className="opacity-80">{data.game.platform_display_name}</p>
</div>;
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`);
previewUrl.searchParams.delete('ts');
previewUrl.searchParams.set('width', "640");
const badges: JSX.Element[] = [];
if (data.game.id.source === 'local')
{
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
}
return <CardElement
badges={badges}
onFocus={data.onFocus}
onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)}
preview={previewUrl.href}
title={data.game.name ?? ""}
subtitle={subtitle}
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
index={data.index}
id={`game-${data.game.id.source}-${data.game.id.id}`}
/>;
}

View file

@ -6,8 +6,7 @@ import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
import { GameCardFocusHandler } from "./GameCard";
import { gameQuery } from "../scripts/queries";
import { GameCardFocusHandler } from "./CardElement";
import { useLocalSetting } from "../scripts/utils";
export interface GameListParams
@ -16,7 +15,7 @@ export interface GameListParams
filters?: GameListFilterType,
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void;
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
onFocus?: GameCardFocusHandler;
className?: string;
}
@ -33,7 +32,7 @@ export function GameList (data: GameListParams)
const queryClient = useQueryClient();
const blur = useLocalSetting('backgroundBlur');
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) =>
{
const game = games.data?.games.find((g) => g.id === id);
if (game)
@ -52,7 +51,7 @@ export function GameList (data: GameListParams)
}
};
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details');
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
@ -73,11 +72,11 @@ export function GameList (data: GameListParams)
const badges: JSX.Element[] = [];
if (g.id.source === 'local')
{
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
}
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
previewUrl.searchParams.delete('ts');
previewUrl.searchParams.set('width', "640");
previewUrl.searchParams.set('width', "16");
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
@ -93,7 +92,7 @@ export function GameList (data: GameListParams)
),
previewUrl: previewUrl.href,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id),
onFocus: () => handleFocus(g.id, g.source, g.source_id)
} satisfies GameMetaExtra;
},

View file

@ -22,10 +22,10 @@ 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 { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
import { RPC_URL } from "../../shared/constants";
import { JSX, useEffect, useRef } from "react";
import { SaveSource } from "../scripts/spatialNavigation";
import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation";
import { systemApi } from "../scripts/clientApi";
import { Router } from "..";
@ -54,14 +54,14 @@ function HeaderAvatar (data: {
id={data.id}
ref={ref}
onClick={data.onSelect}
style={{ viewTransitionName: `header-account-${data.id}` }}
className={classNames(
`avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
`avatar indicator ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
bgColors[data.type ?? "none"],
"text-base-content cursor-pointer transition-all drop-shadow-md",
"hover:ring-primary hover:ring-7",
"hover:ring-primary hover:ring-7 focusable focusable-primary focused:ring-offset-base-100",
{
"ring-5 hover:ring-offset-5": data.active,
"sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused,
"ring-offset-5": focused && data.active,
},
data.className,
@ -276,7 +276,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
{
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<div className="flex sm:gap-2 md:gap-5 items-center">
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
<ClockStatus />
<WiFiStatus />
<BluetoothStatus />
@ -289,22 +289,29 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
key={b.id}
className="header-icon sm:size-10 md:size-16"
id={b.id}
icon={b.icon}
external={b.external}
action={b.action}
/>)}
style={{ viewTransitionName: `header-button-${b.id}` }}
onAction={b.action}
>{b.icon}</RoundButton>)}
</div>
</div>;
}
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
export function HeaderUI (data: {
buttons?: HeaderButton[];
accounts?: HeaderAccount[];
buttonElements?: JSX.Element[] | JSX.Element;
title?: JSX.Element;
preferredChildFocusKey?: string;
})
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
return (
<FocusContext.Provider value={focusKey}>
<header
ref={ref}
className={`flex items-center justify-between text-base-content`}
className="flex items-center justify-between text-base-content"
style={{ viewTimelineName: 'header' }}
>
<HeaderAccounts accounts={data.accounts} />
{data.title}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { GameCardSkeleton } from './GameCard';
import { GameCardSkeleton } from './CardElement';
export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; })
{

View file

@ -0,0 +1,31 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
export default function NotFound ()
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
<TriangleAlert className="size-12" />
Not found
</p>
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
</FocusContext>
</div>;
}

View file

@ -1,16 +1,25 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
import { JSX, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { GameCardFocusHandler } from "./GameCard";
import { GameCardFocusHandler } from "./CardElement";
import { mobileCheck } from "../scripts/utils";
import { twMerge } from "tailwind-merge";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; })
export function PlatformsList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: GameCardFocusHandler;
grid?: boolean;
onSelect?: (source: string, id: string) => void;
})
{
const isMobile = mobileCheck();
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
{
@ -25,6 +34,12 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
staleTime: DefaultRommStaleTime,
});
const handleDefaultSelect = (source: string, id: string) =>
{
SaveSource('game-list');
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
};
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g, i) =>
{
@ -44,13 +59,9 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
onFocus: () => data.setBackground(
g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`,
),
onSelect: () =>
{
SaveSource('game-list');
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
},
onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id),
preview:
({ focused }) => <div
() => <div
className="flex p-6 bg-base-100 justify-center"
style={{
background: `linear-gradient(
@ -58,11 +69,11 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
color-mix(in srgb, var(--color-base-300) 60%, transparent)
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
backgroundBlendMode: "screen",
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
backgroundBlendMode: isMobile ? undefined : "screen",
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
}}
>
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
src={coverUrl.href}
></img>
</div>
@ -76,7 +87,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
type="platform"
id={data.id}
grid={data.grid}
className={data.className}
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
onGameFocus={data.onFocus}
games={platformsMapped}
onSelectGame={(id) =>

View file

@ -1,39 +1,20 @@
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX } from "react";
import { CSSProperties, JSX } from "react";
import { twMerge } from 'tailwind-merge';
import { Button, ButtonStyle } from "./options/Button";
export function RoundButton (data: {
id: string;
icon: JSX.Element;
children?: any;
className?: string;
external?: boolean;
action?: () => void;
})
style?: ButtonStyle;
} & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.id,
onEnterPress: data.action,
});
return (
<div
id={data.id}
ref={ref}
onClick={data.action}
className={classNames(twMerge(
"rounded-full size-14 flex items-center justify-center bg-base-100 text-base-content cursor-pointer transition-all drop-shadow-sm",
data.className, classNames(data.external === true
? {
"hover:ring-7 hover:ring-primary hover:bg-base-content hover:text-base-300": true,
"ring-7 ring-primary bg-base-content text-base-100": focused,
}
: {
"hover:bg-primary hover:text-primary-content": true,
"bg-primary text-primary-content": focused,
},)),
)}
>
{data.icon}
</div>
<Button onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
{data.children}
</Button>
);
}

View file

@ -0,0 +1,49 @@
import { RPC_URL } from "@/shared/constants";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { useRef, useState } from "react";
import FocusDots from "./FocusDots";
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
import { Fullscreen } from "lucide-react";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
{
const imageRef = useRef<HTMLImageElement>(null);
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(),
onFocus: (e, p, details) =>
{
data.setFocused?.(data.index);
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
}
}); 4096;
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={() => imageRef.current?.requestFullscreen()}> <Fullscreen /> </div>
</div>;
}
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
{
const scrollRef = useRef(null);
const { ref, focusKey } = useFocusable({
focusKey: 'screenshot-list',
onFocus: (e, p, details) =>
{
data.onFocus?.(focusKey, ref.current, details);
}
});
useDragScroll(scrollRef);
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
<FocusContext value={focusKey}>
<div
ref={scrollRef}
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
>
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} />)}
</div>
<FocusDots elements={data.screenshots.map((_, i) => `screenshot-${i}`)} />
</FocusContext>
</div>;
}

View file

@ -16,7 +16,7 @@ export default function ShortcutPrompt (data: {
onClick={data.onClick}
style={{ viewTransitionName: data.id }}
className={twMerge("xs:text-xs sm:p-1 sm:text-sm",
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30 active:text-base-300 active:bg-base-content",
data.className,
classNames({
"hover:bg-base-300 cursor-pointer": !!data.onClick,

View file

@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
const { control } = useActiveControl();
const showKeyboard = control === 'keyboard' || control === 'mouse';
return (
<div className="flex gap-2 z-1000 h-10">
<div className="flex gap-2 z-1000" style={{ viewTimelineName: "shortcuts" }}>
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}

View file

@ -7,12 +7,26 @@ import
import classNames from "classnames";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
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",
};
export function Button (data: {
id: string,
children?: any,
className?: string,
disabled?: boolean,
type?: "reset" | "button" | "submit";
style?: ButtonStyle,
shortcutLabel?: string;
focusClassName?: string;
} & InteractParams & FocusParams)
@ -20,7 +34,7 @@ export function Button (data: {
const { ref, focused, focusKey } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
focusable: !data.disabled
});
@ -31,9 +45,10 @@ export function Button (data: {
return <button
ref={ref}
onClick={data.onAction}
onClick={e => data.onAction?.(e.nativeEvent)}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
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",
styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined,
classNames({
"btn-accent": focused,

View file

@ -1,5 +1,5 @@
import { HTMLInputTypeAttribute, JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "../../../shared/constants";
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { useLocalStorage } from "usehooks-ts";
@ -18,7 +18,7 @@ export function LocalOption (data: {
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
return (
<OptionSpace label={data.label}>
<OptionSpace id={`${data.id}-space`} label={data.label}>
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
name={data.id ?? ""}
type={data.type}

View file

@ -1,9 +1,7 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
import { ChevronDown } from "lucide-react";
@ -39,16 +37,13 @@ export function OptionDropdown (data: {
return (
<>
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_button]:not-focus:ring-7 [&_button]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<label ref={ref} className={twMerge("flex group-focusable items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent")}>
{!!data.icon && <span className={"text-base-content/80 is-focused:text-primary-content"}>{data.icon}</span>}
<button onClick={() =>
{
console.log("Open");
setOpen(true);
}} className={classNames('btn input rounded-full cursor-pointer grow', { "bg-base-200": !focused })}>{data.value}<ChevronDown /></button>
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
</label>
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
<ContextList options={data.values.map((v, i) => ({

View file

@ -1,10 +1,9 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { Check, CheckIcon, X } from "lucide-react";
import { CheckIcon, X } from "lucide-react";
export function OptionInput (data: {
name: string;
@ -52,11 +51,8 @@ export function OptionInput (data: {
};
return (
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_.focus-target]:not-focus:ring-7 [&_.focus-target]:not-focus:ring-accent": focused, "pl-1": data.type === 'checkbox' }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
@ -72,17 +68,11 @@ export function OptionInput (data: {
onBlur={data.onBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
"focus-target text-base-content",
"input grow rounded-full ring-primary-content focus:ring-7", classNames({
"bg-base-200": !focused
}),
"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",
data.className
)}
/>}
{data.type === 'checkbox' && <div className={classNames("toggle focus-target toggle-primary toggle-xl border-base-content/30 rounded-full before:rounded-full text-base-content", {
"bg-base-200": !focused,
"border-0": focused,
})}>
{data.type === 'checkbox' && <div className="toggle toggle-xl before:size-6 h-8 border-base-content/30 rounded-full before:rounded-full text-base-content not-in-focus:bg-base-200 focused-child:border-0 ml-1 ring-7 hover:border-base-content focusable focusable-accent">
<input
ref={inputRef}
id={data.name}

View file

@ -1,16 +1,9 @@
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { OptionContext } from "@/mainview/scripts/contexts";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect, useMemo } from "react";
import { JSX, useContext, useEffect, useMemo } from "react";
import { twMerge } from "tailwind-merge";
export const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
{
const context = useContext(OptionContext);
@ -81,11 +74,7 @@ export function OptionSpace (data: {
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5",
classNames(
{
"bg-base-300": focused || hasFocusedChild,
}),
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5 focused:bg-base-300 focused-child:bg-base-300",
data.className,
)}
>

View file

@ -110,7 +110,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
};
return (
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionInput
icon={data.icon}
name={`${data.id}-input`}

View file

@ -18,7 +18,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{
const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex flex-1 gap-2">
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}

View file

@ -54,7 +54,7 @@ export function SettingsOption (data: {
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionSpace id={`${data.id}-space`} label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}

View file

@ -0,0 +1,76 @@
import { useRef } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { ChevronRight, Joystick } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
import FocusDots from "../FocusDots";
import { Router } from "@/mainview";
import { StoreEmulatorCard } from "./StoreEmulatorCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FrontEndEmulator } from "@/shared/constants";
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
onFocus: (_l, _p, details) => data.onFocus?.({ node: ref.current, instant: details.instant }),
onEnterPress: data.onAction
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "See All", action: data.onAction }], []);
return <div
ref={ref}
role="button"
tabIndex={0}
onClick={data.onAction}
className={"flex focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 hover:bg-base-300 cursor-pointer"}
>
See All Emulators <ChevronRight />
</div>;
}
export function EmulatorsSection (data: {
id: string;
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
header?: any;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id),
trackChildren: true,
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
});
const containerRef = useRef(null);
useDragScroll(containerRef);
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-2 py-4">
<div className="flex items-center gap-3 px-4 mb-4 text-info">
{data.header ?? <>
<div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<Joystick />
<h2 className="font-bold uppercase tracking-widest">
Recommended Emulators
</h2>
</>}
</div>
<div ref={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
{data.emulators?.map((em) => (
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
{
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
}} />
))}
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
</div>
</section>
{!!data.emulators && <FocusDots elements={data.emulators.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />}
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,49 @@
import { useRef } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Gamepad2 } from "lucide-react";
import { 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";
export function GamesSection ({ games, onSelect, onFocus }: {
games: FrontEndGameType[];
onSelect?: (id: FrontEndId, focusKey: string) => void;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.GAME_SECTION,
trackChildren: true,
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
});
const containerRef = useRef(null);
useDragScroll(containerRef);
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-6 py-3 select-none">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Featured Games
</h2>
<div className="badge badge-xl badge-accent badge-soft">Curated picks</div>
</div>
<div ref={containerRef} className="grid grid-flow-col auto-cols-[18rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
{games.map((g, i) => <FrontEndGameCard
key={g.id.id}
game={g}
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
index={i} />)}
</div>
</section>
<FocusDots elements={games.map(e => FOCUS_KEYS.GAME_CARD(e.id.id))} />
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,98 @@
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
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 { FOCUS_KEYS } from "@/mainview/scripts/types";
// ── Single missing-emulator card ───────────────────────────────────────────
interface MissingCardProps
{
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
}
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{
const handleSelect = () => onSelect?.(em.name, focusKey);
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.MISSING_CARD(em.name),
onEnterPress: handleSelect,
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse } = useActiveControl();
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(e) => e.key === "Enter" && handleSelect}
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
>
<div className="card-body p-5 gap-3">
<div className="flex gap-4">
<div
className={`size-14 bg-base-content rounded-full flex items-center justify-center text-2xl shadow-md shrink-0 text-base-300`}
>
{em.logo ?
<img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${em.logo}`}></img> :
<CircleQuestionMark />
}
</div>
<div className="grow">
<p className="font-bold text-base-content text-xl leading-tight">{em.name}</p>
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
</div>
</div>
<div className="flex items-center grow h-8">
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
</div>
</div>
</div>
);
}
export function MissingEmulatorsSection ({
emulators,
onSelect,
}: {
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
})
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.MISSING_SECTION,
trackChildren: true,
onFocus: (_l, _p, details) => (ref.current as HTMLElement)?.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'end' })
});
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-6 pt-5 pb-2">
<div className="flex items-center gap-3 mb-4 text-error">
<div className="w-2 h-5 rounded-full bg-error shadow-sm shadow-error/40" />
<SearchAlert />
<h2 className="font-bold uppercase tracking-widest">
Missing Emulators
</h2>
</div>
<div className="grid sm:grid-cols-1 md:grid-cols-3 gap-4">
{emulators.map((em) => (
<MissingCard key={em.name} emulator={em} onSelect={onSelect} />
))}
</div>
</section>
<div className="divider opacity-20" />
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,52 @@
import { storeApi } from "@/mainview/scripts/clientApi";
import { useQuery } from "@tanstack/react-query";
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
interface StatsSectionProps
{
romCount: number;
missingCount: number;
}
export function StatsSection ({
romCount,
missingCount,
}: StatsSectionProps)
{
const { data: stats } = useQuery({
queryKey: ['store', 'stats'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.stats.get();
if (error) throw error;
return data;
}
});
return (
<section className="px-6 pt-3 pb-4">
<div className="stats stats-horizontal w-full rounded-2xl text-shadow-sm">
<div className="stat">
<div className="stat-figure text-2xl text-primary shadow-2xl"><Joystick /></div>
<div className="stat-value text-xl font-black text-primary shadow-2xl">{stats?.storeEmulatorCount}</div>
<div className="stat-desc ">Emulators Available</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-secondary"><Save /></div>
<div className="stat-value text-xl font-black text-secondary">{romCount.toLocaleString()}+</div>
<div className="stat-desc">ROMs in Store</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-success"><LibraryBig /></div>
<div className="stat-value text-xl font-black text-success">{stats?.gameCount}</div>
<div className="stat-desc">Your Library</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-warning"><TriangleAlert /></div>
<div className="stat-value text-xl font-black text-warning">{missingCount}</div>
<div className="stat-desc">Missing Emulators</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,84 @@
import { twMerge } from "tailwind-merge";
import { FrontEndEmulator, 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, HardDrive } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
export function StoreEmulatorCard (data: {
id: string;
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
onFocus?: (data: { id: string; node: HTMLElement; details: Record<string, any>; }) => void;
className?: string;
})
{
const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey);
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id),
onEnterPress: handleSelect,
onFocus: (_l, _p, details) =>
{
data.onFocus?.({ id: data.emulator.name, node: ref.current as HTMLElement, details });
}
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse, isTouch } = useActiveControl();
return (
<div
ref={ref}
role="button"
tabIndex={0}
data-installed={data.emulator.exists ? true : undefined}
onClick={isTouch ? handleSelect : undefined}
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
>
<div className="flex flex-col justify-between p-4 gap-2 h-full">
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="flex items-start">
<div
data-installed={data.emulator.exists}
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
>
<img draggable={false} src={data.emulator.logo}></img>
</div>
</div>
<div>
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
<ul className="flex flex-wrap gap-1">
{data.emulator.systems.map(({ id, name, icon }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!icon && <img draggable={false} className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
</div>;
})}
</ul>
</div>
</div>
</div>
<div className="flex gap-0.5 mt-1 h-10 items-center">
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
</div>}
{<div className="tooltip" data-tip="Game Count">
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
</div>}
{isMouse && <>
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
<Button className="bg-transparent border-none shadow-none w-6 p-0" id={`${data.emulator.name}-options`} ><EllipsisVertical /></Button>
</>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { RPC_URL } from '@/shared/constants';
import { basename } from 'pathe';
const params = new URLSearchParams(window.location.search);
Array.from(params.entries()).forEach(([key, value]) =>
{
(window as any)[`EJS_${key}`] = value;
});
window.addEventListener('message', (e) =>
{
switch (e.data.type)
{
case 'pause':
if (e.data.data === true)
{
window.EJS_emulator.pause();
} else
{
window.EJS_emulator.play();
}
break;
case 'restart':
window.EJS_emulator.elements.bottomBar.restart[0].click();
break;
}
});
window.EJS_player = "#game";
window.EJS_lightgun = false;
window.EJS_startOnLoaded = true;
// For core downloads, it either redirects to CDN or uses local if downloaded
window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`;
window.EJS_Buttons = {
exitEmulation: {
visible: true,
displayName: "Exit",
callback: () =>
{
window.parent.postMessage(
{ type: "exit" },
"*"
);
}
}
};
const moduleUrls = import.meta.glob
(['../../../node_modules/@emulatorjs/emulatorjs/data/**/*.js',
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.css',
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.wasm',
'../../../node_modules/@emulatorjs/emulatorjs/data/localization/en-US.json'
], {
query: '?url',
import: 'default',
});
// 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');

View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="../assets/favicon.ico" />
<link
href="https://fonts.googleapis.com/css2?family=Alan+Sans:wght@300..900&display=swap"
rel="stylesheet"
/>
<link href="./style.css" rel="stylesheet" />
<title>GameFlow</title>
</head>
<body>
<script type="module" src="./emulator.ts"></script>
<div id="game-wrapper">
<div id="game"></div>
</div>
</body>
</html>

View file

@ -0,0 +1,22 @@
html {
width: 100%;
height: 100%;
overflow: hidden;
padding: 0;
margin: 0;
}
body {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
#game-wrapper {
padding: 0;
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}

62
src/mainview/emulatorjs/types.d.ts vendored Normal file
View file

@ -0,0 +1,62 @@
export declare global
{
interface Window
{
EJS_emulator: any,
EJS_player: string,
EJS_gameUrl: string,
EJS_pathtodata: string,
EJS_language: string,
EJS_disableAutoLang: boolean,
EJS_paths: Record<string, string>,
EJS_volume: number,
EJS_gameName: string,
EJS_cheats: string[][],
EJS_fullscreenOnLoaded: boolean,
EJS_startOnLoaded: boolean,
EJS_core: string,
EJS_lightgun: boolean,
EJS_biosUrl: string,
EJS_color: string,
EJS_AdUrl: string,
EJS_AdMode: string,
EJS_AdTimer: number,
EJS_AdSize: number,
EJS_alignStartButton: boolean,
EJS_VirtualGamepadSettings,
EJS_Buttons,
EJS_defaultControls,
EJS_loadStateURL: string,
EJS_CacheLimit: number,
EJS_cacheConfig,
EJS_cheatPath: string,
EJS_defaultOptions,
EJS_gamePatchUrl: string,
EJS_gameParentUrl: string,
EJS_netplayServer,
EJS_netplayICEServers,
EJS_gameID: string,
EJS_backgroundImage: string,
EJS_backgroundBlur,
EJS_backgroundColor,
EJS_controlScheme,
EJS_threads: boolean,
EJS_disableCue,
EJS_startButtonName,
EJS_softLoad,
EJS_screenCapture,
EJS_externalFiles,
EJS_dontExtractRom,
EJS_dontExtractBIOS,
EJS_disableLocalStorage: boolean,
EJS_forceLegacyCores: boolean,
EJS_noAutoFocus: boolean,
EJS_videoRotation,
EJS_hideSettings,
EJS_browserMode,
EJS_shaders,
EJS_fixedSaveInterval,
EJS_disableAutoUnload,
EJS_disableBatchBootup;
}
}

View file

@ -17,9 +17,15 @@ import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/di
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
import { Route as CollectionIdRouteImport } from './../routes/collection.$id'
import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings',
@ -61,6 +67,26 @@ const CollectionIdRoute = CollectionIdRouteImport.update({
path: '/collection/$id',
getParentRoute: () => rootRouteImport,
} as any)
const StoreTabRouteRoute = StoreTabRouteRouteImport.update({
id: '/store/tab',
path: '/store/tab',
getParentRoute: () => rootRouteImport,
} as any)
const StoreTabIndexRoute = StoreTabIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const StoreTabGamesRoute = StoreTabGamesRouteImport.update({
id: '/games',
path: '/games',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
id: '/emulators',
path: '/emulators',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
id: '/platform/$source/$id',
path: '/platform/$source/$id',
@ -76,19 +102,35 @@ const GameSourceIdRoute = GameSourceIdRouteImport.update({
path: '/game/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const EmbeddedSourceIdRoute = EmbeddedSourceIdRouteImport.update({
id: '/embedded/$source/$id',
path: '/embedded/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
id: '/store/details/emulator/$id',
path: '/store/details/emulator/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/store/tab': typeof StoreTabRouteRouteWithChildren
'/collection/$id': typeof CollectionIdRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/': typeof StoreTabIndexRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -99,38 +141,55 @@ export interface FileRoutesByTo {
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab': typeof StoreTabIndexRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/store/tab': typeof StoreTabRouteRouteWithChildren
'/collection/$id': typeof CollectionIdRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/': typeof StoreTabIndexRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/settings'
| '/store/tab'
| '/collection/$id'
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/embedded/$source/$id'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab/'
| '/store/details/emulator/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -141,31 +200,45 @@ export interface FileRouteTypes {
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/embedded/$source/$id'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab'
| '/store/details/emulator/$id'
id:
| '__root__'
| '/'
| '/settings'
| '/store/tab'
| '/collection/$id'
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/embedded/$source/$id'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/store/tab/emulators'
| '/store/tab/games'
| '/store/tab/'
| '/store/details/emulator/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
CollectionIdRoute: typeof CollectionIdRoute
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
GameSourceIdRoute: typeof GameSourceIdRoute
LauncherSourceIdRoute: typeof LauncherSourceIdRoute
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
}
declare module '@tanstack/react-router' {
@ -226,6 +299,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CollectionIdRouteImport
parentRoute: typeof rootRouteImport
}
'/store/tab': {
id: '/store/tab'
path: '/store/tab'
fullPath: '/store/tab'
preLoaderRoute: typeof StoreTabRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/store/tab/': {
id: '/store/tab/'
path: '/'
fullPath: '/store/tab/'
preLoaderRoute: typeof StoreTabIndexRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/store/tab/games': {
id: '/store/tab/games'
path: '/games'
fullPath: '/store/tab/games'
preLoaderRoute: typeof StoreTabGamesRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/store/tab/emulators': {
id: '/store/tab/emulators'
path: '/emulators'
fullPath: '/store/tab/emulators'
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
parentRoute: typeof StoreTabRouteRoute
}
'/platform/$source/$id': {
id: '/platform/$source/$id'
path: '/platform/$source/$id'
@ -247,6 +348,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GameSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/embedded/$source/$id': {
id: '/embedded/$source/$id'
path: '/embedded/$source/$id'
fullPath: '/embedded/$source/$id'
preLoaderRoute: typeof EmbeddedSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/store/details/emulator/$id': {
id: '/store/details/emulator/$id'
path: '/store/details/emulator/$id'
fullPath: '/store/details/emulator/$id'
preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@ -270,13 +385,32 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
SettingsRouteRouteChildren,
)
interface StoreTabRouteRouteChildren {
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
StoreTabGamesRoute: typeof StoreTabGamesRoute
StoreTabIndexRoute: typeof StoreTabIndexRoute
}
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
StoreTabGamesRoute: StoreTabGamesRoute,
StoreTabIndexRoute: StoreTabIndexRoute,
}
const StoreTabRouteRouteWithChildren = StoreTabRouteRoute._addFileChildren(
StoreTabRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SettingsRouteRoute: SettingsRouteRouteWithChildren,
StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
CollectionIdRoute: CollectionIdRoute,
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
GameSourceIdRoute: GameSourceIdRoute,
LauncherSourceIdRoute: LauncherSourceIdRoute,
PlatformSourceIdRoute: PlatformSourceIdRoute,
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

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

View file

@ -8,15 +8,18 @@
--breakpoint-sm: 0px;
--breakpoint-md: 1280px;
--page-scroll-bg: transparent;
--animation-size: 1;
--animate-wiggle: wiggle 0.3s ease-in-out 1;
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 0.2s;
--animate-rotate-instant: rotate 0.3s ease-in-out 1;
--animate-rotate: rotate 0.3s ease-in-out 1 200ms;
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 200ms;
--animate-scale: scale 0.3s ease-in-out 1;
--animate-slide-up: slide-up 0.2s ease-in-out 1;
--animate-scale-delayed: scale 0.3s ease-in-out 1 100ms;
--animate-scale-small: scale-small 0.3s ease-in-out 1;
--animate-fade-out: fade-out 0.3s ease-out 1;
--animate-fade-in: fade-out 0.6s ease-out 1 reverse forwards;
--animate-bg-zoom: zoom-in-scale 0.6s ease-out 1 forwards;
--animate-bg-zoom-big: zoom-in-scale-big 0.6s ease-out 1 forwards;
--animate-bg-zoom-scroll: zoom-in-bg 0.6s ease-out 1 forwards;
@ -116,11 +119,11 @@
}
25% {
transform: rotate(1deg);
transform: rotate(calc(1deg * var(--animation-size)));
}
75% {
transform: rotate(-1deg);
transform: rotate(calc(-1deg * var(--animation-size)));
}
}
@ -197,8 +200,88 @@ body {
font-family: 'Alan Sans', sans-serif;
}
@utility focusable-* {
--focus-ring-color: --value(--color-*);
}
@utility hide-scrollbar {
scrollbar-width: none;
}
@utility no-scrollbar {
scrollbar-width: none;
}
@utility scrollbar-* {
scrollbar-width: --value(integer);
scrollbar-width: --value([integer]);
scrollbar-width: --value("none");
}
@utility animation-size-* {
--animation-size: --value(number);
}
@utility animation-delay-* {
--tw-animation-delay: --value([*]);
--tw-animation-delay: --value(integer)ms;
animation-delay: var(--tw-animation-delay);
}
@custom-variant focused {
&:where([data-active-control="gamepad"] &[data-focused=true]),
&:where([data-active-control="keyboard"] &[data-focused=true]) {
@slot;
}
}
@custom-variant focused-child {
&:where([data-active-control="gamepad"] &:has([data-focused=true])),
&:where([data-active-control="keyboard"] &:has([data-focused=true])) {
@slot;
}
}
@custom-variant in-focused {
&:where([data-active-control="gamepad"] [data-focused=true] *),
&:where([data-active-control="keyboard"] [data-focused=true] *) {
@slot;
}
}
@custom-variant control-mouse (&:where([data-active-control="mouse"] *));
@custom-variant control-touch (&:where([data-active-control="touch"] *));
@custom-variant control-keyboard (&:where([data-active-control="keyboard"] *));
@custom-variant control-gamepad (&:where([data-active-control="gamepad"] *));
@custom-variant control-pointer (&:where([data-active-control="mouse"] *), &:where([data-active-control="touch"] *));
@custom-variant mobile (&:where([data-device="mobile"] *));
@container scroll-state(stuck: top) {
.sticky-header {
background-color: var(--color-base-100);
}
}
@layer components {
.focusable {
--focus-ring-color: --value(--color-accent);
@apply md:ring-14 sm:ring-8 ring-transparent transition-shadow;
}
[data-active-control="keyboard"] .group-focusable[data-focused="true"] .focusable,
[data-active-control="gamepad"] .group-focusable[data-focused="true"] .focusable,
[data-active-control="keyboard"] .focusable[data-focused=true],
[data-active-control="gamepad"] .focusable[data-focused=true],
[data-active-control="mouse"] .group-focusable:hover .focusable-hover,
[data-active-control="mouse"] .focusable-hover:hover {
@apply md:ring-7 sm:ring-4 ring-(--focus-ring-color);
}
.background {
-webkit-backface-visibility: hidden;
-webkit-perspective: 1000;
@ -210,6 +293,17 @@ body {
transform: translateZ(0);
}
.container-scroll {
container-type: scroll-state;
}
.sticky-header {
position: sticky;
top: 0;
left: 0;
right: 0;
}
.game-card {
@apply rounded-2xl;
}
@ -245,8 +339,52 @@ body {
@apply grid pb-4;
}
.no-scrollbar {
scrollbar-width: none;
.bg-gradient {
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
--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%);
background-blend-mode: lighten;
background-repeat: repeat;
background-color: var(--color-base-100);
@apply mobile:hidden;
}
.bg-noise {
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
background-image: url("https://momentsingraphics.de/Media/BlueNoise/BlueNoise470.png");
mix-blend-mode: color-dodge;
opacity: 0.1;
}
.bg-gradient-back {
--bg-opacity: 90%;
background:
radial-gradient(at 10% 20%, color-mix(in srgb, var(--color-secondary), transparent var(--bg-opacity)), transparent 60%),
radial-gradient(at 80% 30%, color-mix(in srgb, var(--color-info), transparent var(--bg-opacity)), transparent 60%),
radial-gradient(at 40% 90%, color-mix(in srgb, var(--color-success), transparent var(--bg-opacity)), transparent 60%),
radial-gradient(at 90% 80%, color-mix(in srgb, var(--color-warning), transparent var(--bg-opacity)), transparent 60%);
background-blend-mode: screen;
background-repeat: repeat;
}
@keyframes scroll-bg {
to {
transform: translateY(800px);
}
}
&::view-transition-old(.game-card),

View file

@ -15,6 +15,8 @@ import "./scripts/gamepads";
import "./scripts/windowEvents";
import { client as rommClient } from "../clients/romm/client.gen";
import "./scripts/spatialNavigation";
import NotFound from "./components/NotFound";
import Error from "./components/Error";
const hashHistory = createHashHistory({});
@ -38,15 +40,9 @@ export const Router = createRouter({
defaultPreload: "intent",
context: { queryClient },
scrollRestoration: false,
defaultNotFoundComponent: () =>
{
return (
<div>
<p> {window.location.href} Not found!</p>
<Link to="/">Go home</Link>
</div>
);
},
defaultNotFoundComponent: NotFound,
defaultPendingMs: 300,
defaultErrorComponent: Error
});
// Register things for typesafety

View file

@ -1,10 +1,9 @@
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterContext } from "..";
import Notifications from "../components/Notifications";
import { Toaster } from "react-hot-toast";
import { mobileCheck, useLocalSetting } from "../scripts/utils";
import useActiveControl from "../scripts/gamepads";
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@ -14,18 +13,19 @@ function RootComponent ()
{
const isMobile = mobileCheck();
const theme = useLocalSetting('theme');
const { control } = useActiveControl();
return (
<div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
<div data-theme={theme === 'auto' ? undefined : theme} data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
<Outlet />
<Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
{import.meta.env.DEV && !isMobile &&
{/*import.meta.env.DEV && !isMobile &&
<>
<TanStackRouterDevtools position="top-left" />
<ReactQueryDevtools buttonPosition="top-right" />
</>
}
*/}
</div >
);
}

View file

@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../components/CollectionsDetail';
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../shared/constants';
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '@shared/constants';
import { useQuery } from '@tanstack/react-query';
import { useContext } from 'react';
import { AnimatedBackgroundContext } from '../scripts/contexts';
export const Route = createFileRoute('/collection/$id')({
component: RouteComponent,
@ -17,12 +18,9 @@ function RouteComponent ()
{
const { id } = Route.useParams();
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const animatedBgContext = useContext(AnimatedBackgroundContext);
return (
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
<CollectionsDetail setBackground={animatedBgContext.setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
);
}

View file

@ -0,0 +1,185 @@
import { EMULATORJS_URL, RPC_URL, SERVER_URL } from '@/shared/constants';
import { createFileRoute } from '@tanstack/react-router';
import { gameQuery } from '../scripts/queries';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { RefObject, useEffect, useRef, useState } from 'react';
import { Router } from '..';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { Button, ButtonStyle } from '../components/options/Button';
import { DoorOpen, Home, RefreshCw, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import Shortcuts from '../components/Shortcuts';
import { useEventListener, useTimeout } from 'usehooks-ts';
import { GetFocusedElement, useGlobalFocus } from '../scripts/spatialNavigation';
import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge';
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
import { RoundButton } from '../components/RoundButton';
export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent,
loader: async (ctx) =>
{
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
return { data };
},
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
});
function OverlayButton (data: {
id: string,
style: ButtonStyle,
tooltip: string, setTooltip: (tooltip: string) => void,
className?: string;
children?: any;
} & InteractParams)
{
return <div className="tooltip tooltip-bottom" data-tip={data.tooltip}>
<RoundButton external onFocus={() => data.setTooltip(data.tooltip)} style={data.style} className={twMerge("", data.className)} id={data.id} onAction={data.onAction} >
{data.children}
</RoundButton>
</div>;
}
function Overlay (data: {
open: boolean;
iframeRef: RefObject<HTMLIFrameElement | null>;
close: () => void;
goBack: () => void;
})
{
const { ref, focusSelf, focusKey } = useFocusable({ focusable: data.open, focusKey: 'overlay', forceFocus: true, isFocusBoundary: true });
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
useShortcuts(focusKey, () => data.open ? [{ label: 'Return', button: GamePadButtonCode.B, action: data.close }] : [], [data.open, data.close]);
useEffect(() =>
{
if (data.open)
{
focusSelf();
}
}, [data.open]);
const { isPointer } = useActiveControl();
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
<div className='grid grid-cols-3 justify-between items-start'>
<div className='flex justify-start'>
<HeaderAccounts />
</div>
<div className='flex justify-center'>
<ul ref={ref} className='flex rounded-4xl bg-base-100 justify-end gap-2 p-4 group-data-[open=true]:animate-scale'>
<FocusContext value={focusKey}>
<OverlayButton id="return" style='primary' tooltip='Return' setTooltip={setTooltip} onAction={data.close} ><Undo /></OverlayButton>
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
{
data.close();
handleEvent('restart');
}} ><RefreshCw /></OverlayButton>
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
</FocusContext>
</ul>
</div>
<div className='flex justify-end'>
<HeaderStatusBar />
</div>
</div>
<div className='flex justify-center'>
{!!tooltip && data.open && !isPointer && <div className='bg-accent text-accent-content rounded-full font-semibold py-1 px-4'>{tooltip}</div>}
</div>
</div>;
}
function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
{
const { ref } = useFocusable({ focusKey: 'frame' });
const { data: game } = Route.useLoaderData();
const search = Route.useSearch();
search['gameName'] = game.name;
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
search['backgroundBlur'] = "true";
if (!__PUBLIC__)
{
search['threads'] = "true";
}
const params = Object.entries(search)
.filter(kvp => kvp[1] !== null && kvp[1] !== undefined)
.map(kvp => `${kvp[0]}=${encodeURIComponent(kvp[1]!)}`).join('&');
return <iframe ref={r =>
{
ref.current = r;
data.ref.current = r;
}}
allow='fullscreen; cross-origin-isolated'
className='absolute w-full h-full transition-[padding]' src={
__PUBLIC__ ? `${SERVER_URL(__HOST__)}/emulatorjs/?${params}` : `${EMULATORJS_URL(__HOST__)}/?${params}`
}></iframe>;
}
function RouteComponent ()
{
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: 'emulatorjs',
preferredChildFocusKey: 'frame',
forceFocus: true
});
const iframeRef = useRef<HTMLIFrameElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const { source, id } = Route.useParams();
function HandleGoBack ()
{
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
}
useEventListener('message', e =>
{
if (e.data.type === 'exit')
{
HandleGoBack();
}
});
useShortcuts(focusKey, () => [{
button: GamePadButtonCode.Steam, action: () =>
{
setOverlayOpen(!overlayOpen);
}
}], [overlayOpen, setOverlayOpen]);
const setPaused = (paused: boolean) =>
{
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true });
else
{
// we want to prevent input from closing the overlay spilling
setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100);
}
};
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
const { shortcuts } = useShortcutContext();
useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]);
function handleClose ()
{
setOverlayOpen(false);
}
return <div ref={ref} className='absolute w-full h-full'>
<FocusContext value={focusKey}>
<Frame ref={iframeRef} />
<div className='flex fixed left-0 right-0 top-0'>
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
</div>
<div className='flex justify-end fixed bottom-4 right-4 left-4 z-10'>
<Shortcuts shortcuts={shortcuts} />
</div>
</FocusContext>
</div>;
}

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
import { twJoin, twMerge } from "tailwind-merge";
import { JSX, RefObject, useEffect, useRef, useState } from "react";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
@ -17,32 +17,124 @@ import { ContextDialog, ContextList, DialogEntry } from "../../components/Contex
import Shortcuts from "../../components/Shortcuts";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import { gameQuery } from "@/mainview/scripts/queries";
import Screenshots from "@/mainview/components/Screenshots";
import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils";
import useActiveControl from "@/mainview/scripts/gamepads";
export const Route = createFileRoute("/game/$source/$id")({
loader: ({ params, context }) =>
loader: async ({ params, context }) =>
{
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
return { data };
},
component: GameDetailsUI,
pendingComponent: GameDetailsUIPending,
errorComponent: Error
});
function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
focusSelf();
}, []);
return <AnimatedBackground ref={ref} backgroundKey="game-details">
<div className="relative z-10 h-full">
<FocusContext value={focusKey}>
<div className="h-0" />
<div className="fixed group top-0 left-0 right-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
</div>
<div className="bg-base-200">
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
</AnimatedBackground>;
}
function GameDetailsUIPending ()
{
return <AnimatedBackground>
<div className="flex flex-col p-2 px-3 w-full h-full">
<HeaderUI />
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
focusSelf();
}, []);
return <AnimatedBackground ref={ref} backgroundKey="game-details">
<div className="z-10">
<FocusContext value={focusKey}>
<div className="h-0" />
<div className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
<div className="skeleton w-full h-full"></div>
</div>
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
<Detail icon={<Clock />} ></Detail>
<Detail icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></Detail>
<Detail icon={
<Store />
} >
</Detail>
</div>
<div className="md:hidden divider divider-vertical m-0"></div>
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-[60%]"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-[80%]"></div>
</div>
</div>
</div>
</section>
</main>
</div>
<div className="bg-base-200">
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
<div className="flex flex-col w-full z-0 min-h-0">
<div
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
>
{Array.from({ length: 5 }).map((s, i) => <div key={i} className="skeleton h-64 w-lg"></div>)}
</div>
</div>
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
</AnimatedBackground>;
}
function HandleGoBack ()
{
const source = PopSource('details');
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
const { to, search } = PopSource('details');
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
@ -50,7 +142,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
const { ref, focusKey } = useFocusable({
focusKey: 'main-details', onFocus: () =>
{
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
},
preferredChildFocusKey: "play-btn",
saveLastFocusedChild: false
@ -77,10 +169,10 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
<FocusContext value={focusKey}>
<section className="flex portrait:flex-col my-4 sm:p-0 md:p-12 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24">
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
{gameCoverImg ?
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover" src={gameCoverImg}></img> :
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
<div className="skeleton w-full h-full"></div>
}
</div>
@ -101,7 +193,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div>
<div className="md:hidden divider divider-vertical m-0"></div>
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden ">
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
@ -118,60 +210,6 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
</main>;
}
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onFocus: (e, p, details) =>
{
data.setFocused?.(data.index);
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
}
}); 4096;
return <img className={twJoin("max-h-[60vh] rounded-3xl", classNames({
"sm:ring-4 md:ring-7 ring-primary": focused,
"cursor-pointer": !focused
}))} onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
}
function Screenshots (data: { screenshots: string[]; })
{
const scrollRef = useRef(null);
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
const { ref, focusKey } = useFocusable({
focusKey: 'screenshot-list',
onFocus: (e, p, details) =>
{
if (!(details.nativeEvent instanceof TouchEvent))
{
(ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
}
},
onBlur: () => setFocusedScreenshot(-1)
});
return <div ref={ref} className="flex flex-col w-full z-0">
<FocusContext value={focusKey}>
<div
ref={scrollRef}
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
>
{data.screenshots.map((s, i) => <Screenshot key={s} setFocused={setFocusedScreenshot} index={i} path={s} />)}
</div>
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
{
const focused = i === focusedScreenshot;
return <button key={i} onClick={(e) => setFocus(`screenshot-${i}`, { nativeEvent: e.nativeEvent })}
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
})}</div>
</FocusContext>
</div>;
}
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
{
if (!data.game.achievements)
@ -221,6 +259,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined);
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
const queryClient = useQueryClient();
useEffect(() =>
@ -233,13 +272,14 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
setProgress(stats.progress);
setStatus(stats.status);
setDetails(stats.details);
setCommands(stats.commands);
setError(stats.error);
};
es.addEventListener('refresh', () =>
{
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
location.reload();
Router.navigate({ to: '/game/$source/$id', params: { id, source } });
});
es.addEventListener('error', (e) =>
@ -248,6 +288,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
const stats = JSON.parse((e as any).data) as GameInstallProgress;
toast.error(stats.error);
setError(stats.error);
}
});
@ -257,6 +298,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
if (error)
{
toast.error(error);
setError(error);
}
};
@ -279,9 +321,18 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
mainButton = <ActionButton onAction={() =>
{
playMutation.mutate();
SaveSource('launch');
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
const firstValid = commands?.find(c => c.valid);
if (firstValid?.emulator === 'emulatorjs')
{
const params = new URLSearchParams(firstValid.command);
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
} else
{
playMutation.mutate();
SaveSource('launch');
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
}
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
}
else if (error)
@ -383,6 +434,8 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
}, ref);
const { isPointer } = useActiveControl();
const tooltipStyles = {
base: 'bg-base-100 text-base-content',
accent: 'bg-accent text-accent-content',
@ -403,7 +456,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
}}>
<ContextList options={contextOptions} />
</ContextDialog>
{!!hoverText && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
</FocusContext>
</div>;
}
@ -434,41 +487,35 @@ function ActionButton (data: {
{
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
const styles = {
primary: twMerge("bg-primary text-primary-content",
classNames({
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
})),
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
})),
accent: twMerge("bg-primary text-primary-content ", classNames({
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
})),
error: twMerge("bg-error text-error-content ", classNames({
"bg-error text-error-content sm:ring-4 md:ring-7 ring-primary": focused
})),
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
};
return (
<button
disabled={data.disabled}
ref={ref}
onClick={data.onAction}
data-tooltip={data.tooltip}
data-tooltip_type={data.tooltip_type}
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
{data.icon}
{data.children}
</button>
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
<button
disabled={data.disabled}
ref={ref}
onClick={data.onAction}
data-tooltip={data.tooltip}
data-tooltip_type={data.tooltip_type}
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
{data.icon}
{data.children}
</button>
</div>
);
}
export default function GameDetailsUI ()
{
const { source, id } = Route.useParams();
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
const { data } = Route.useLoaderData();
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = data?.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
@ -476,27 +523,26 @@ export default function GameDetailsUI ()
useEffect(() =>
{
if (isSuccess)
{
focusSelf();
}
focusSelf();
}, []);
}, [isSuccess]);
useStickyDataAttr(headerRef, sentinelRef, ref);
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
<div className="z-0">
<div className="z-10">
<FocusContext value={focusKey}>
<div className="flex flex-col px-3 py-2 h-[90vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="bg-base-200">
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
<div className="flex gap-2 text-sm">
</div>
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>

View file

@ -4,12 +4,12 @@ import
Gamepad2,
Settings,
MessageSquare,
ShoppingBag,
Image,
Search,
Power,
OctagonAlert,
Maximize,
Store,
} from "lucide-react";
import
{
@ -21,13 +21,14 @@ import
{
FocusContext,
FocusDetails,
getCurrentFocusKey,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { useEventListener } from "usehooks-ts";
import { HeaderAccounts, HeaderStatusBar, HeaderUI } from "../components/Header";
import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
import { FilterUI } from "../components/Filters";
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
import { AnimatedBackground } from "../components/AnimatedBackground";
import { GameList } from "../components/GameList";
import { SaveSource } from "../scripts/spatialNavigation";
import LoadingCardList from "../components/LoadingCardList";
@ -43,7 +44,9 @@ import z from "zod";
import { Router } from "..";
import CollectionList from "../components/CollectionList";
import { zodValidator } from '@tanstack/zod-adapter';
import { mobileCheck } from "../scripts/utils";
import { mobileCheck, useDragScroll } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import { FrontEndId } from "@/shared/constants";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@ -93,6 +96,7 @@ function HomeList (data: {
{
const [initFocus, setInitFocus] = useState(false);
const bg = useContext(AnimatedBackgroundContext);
const { } = Route.useSearch;
const { ref, focused, focusKey, focusSelf } = useFocusable({
focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list`
@ -103,18 +107,54 @@ function HomeList (data: {
const isMounseEvent = details.nativeEvent instanceof MouseEvent;
if (!isMounseEvent)
{
node?.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' });
}
setInitFocus(true);
};
const lists: Record<string, JSX.Element> = {
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { filter: data.selectedFilter } });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
};
const handleCollectionSelect = (id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
};
const handlePlatformSelect = (source: string, id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
};
let activeList: JSX.Element;
switch (data.selectedFilter)
{
case 'consoles':
activeList = <>
<PlatformsList onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
case 'collections':
activeList = <>
<CollectionList onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
default:
activeList = <>
<GameList onGameSelect={handleGameSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
}
useEventListener('wheel', e =>
{
const deltaY = e.deltaY;
@ -138,17 +178,18 @@ function HomeList (data: {
}
});
useDragScroll(ref);
return (
<FocusContext value={focusKey}>
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:pt-2 md:py-6 md:pb-3 md:mb-1" style={{
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" style={{
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
}}>
<div className="landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full">
<div className="landscape:flex landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full landscape:items-center">
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
{lists[data.selectedFilter]}
{activeList}
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />
<AutoFocus focus={focusSelf} delay={10} />
</Suspense>
</ErrorBoundary>
</div>
@ -179,7 +220,7 @@ function MainMenu (data: {})
type="secondary"
/>
<CircleIcon icon={<MessageSquare />} label="News" />
<CircleIcon icon={<ShoppingBag />} label="Shop" />
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
<CircleIcon icon={<Image />} label="Album" />
<CircleIcon
icon={<Gamepad2 />}
@ -202,7 +243,7 @@ function MainMenu (data: {})
function CircleIcon (data: {
action?: () => void;
type?: "secondary" | "accent";
type?: "secondary" | "accent" | "info";
label?: string;
icon?: JSX.Element;
})
@ -215,6 +256,7 @@ function CircleIcon (data: {
const typeClasses = {
secondary: "bg-secondary text-secondary-content",
accent: "bg-accent text-accent-content",
info: "bg-info text-info-content",
none: "bg-base-content",
};
return (
@ -222,15 +264,9 @@ function CircleIcon (data: {
ref={ref}
onClick={data.action}
className={twMerge(
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
typeClasses[data.type ?? "none"], classNames(
{
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
"hover:ring-7 hover:ring-primary": true,
})
)}
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])}
>
{data.icon}
<div className="in-focused:animate-rotate-instant animation-size-5">{data.icon}</div>
</li>
);
}
@ -291,11 +327,11 @@ export default function ConsoleHomeUI ()
<div className="sm:landscape:hidden md:landscape:inline sm:portrait:col-start-1 md:inline flex col-span-1 md:pl-2 md:pt-2">
<HeaderAccounts />
</div>
<div className="sm:portrait:*:justify-center sm:portrait:col-span-3 sm:landscape:*:justify-start sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:*:justify-center! md:ml-0 gap-2 *:w-full *:flex">
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
<FilterUI
id="home"
options={filters}
selected={filter ? filter : 'games'}
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
setSelected={setFilter}
/>
</div>
@ -311,7 +347,7 @@ export default function ConsoleHomeUI ()
<MainMenu />
</div>
<footer className={twMerge(
"sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 md:relative px-2 pb-2 flex items-end justify-end",
"fixed bottom-4 left-4 right-4 sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 flex items-end justify-end",
)}>
<Shortcuts shortcuts={shortcuts} />
</footer>

View file

@ -2,9 +2,8 @@ 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 { useEventListener } from 'usehooks-ts';
import { Router } from '..';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { rommApi } from '../scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';

View file

@ -1,21 +1,21 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEventListener, useSessionStorage } from "usehooks-ts";
import { createFileRoute } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { Suspense } from "react";
import { useContext } from "react";
import { rommApi } from "../scripts/clientApi";
import { AnimatedBackgroundContext } from "../scripts/contexts";
export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent
});
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
function PlatformTitle (data: { pathCover: string | null, platformName?: string; })
{
return <div className="sm:landscape:hidden flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0">
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
{!!data.pathCover && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${data.pathCover}`} ></img>}
{data.platformName}
</div>
</div>;
@ -33,16 +33,13 @@ function RouteComponent ()
}, staleTime: DefaultRommStaleTime
});
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const animatedBgContext = useContext(AnimatedBackgroundContext);
return (
<div className="w-full h-full">
{!!platform && <CollectionsDetail
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
setBackground={setBackground}
title={<PlatformTitle pathCover={platform.path_cover} platformName={platform.name} />}
setBackground={animatedBgContext.setBackground}
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
/>}
</div>

View file

@ -1,6 +1,7 @@
import { systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
component: RouteComponent,
@ -9,56 +10,58 @@ export const Route = createFileRoute('/settings/about')({
function RouteComponent ()
{
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
return <div className="overflow-x-auto">
<table className="table">
<tbody>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>
</tr>
{/* row 2 */}
<tr>
<th>Platform</th>
<td>{navigator.platform}</td>
</tr>
<tr>
<th>Resolution</th>
<td>{screen.width}x{screen.height}</td>
</tr>
<tr>
<th>Window</th>
<td>{window.innerWidth}x{window.innerHeight}</td>
</tr>
{/* row 3 */}
<tr>
<th>User</th>
<td>{systemInfo?.data?.user}</td>
</tr>
<tr>
<th>Architecture</th>
<td>{systemInfo?.data?.arch}</td>
</tr>
<tr>
<th>System</th>
<td>{systemInfo?.data?.platform}</td>
</tr>
<tr>
<th>Hostname</th>
<td>{systemInfo?.data?.hostname}</td>
</tr>
<tr>
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</table>
</div>;
return <table className="table">
<tbody>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>
</tr>
{/* row 2 */}
<tr>
<th>Platform</th>
<td>{navigator.platform}</td>
</tr>
<tr>
<th>Resolution</th>
<td>{screen.width}x{screen.height}</td>
</tr>
<tr>
<th>Window</th>
<td>{window.innerWidth}x{window.innerHeight}</td>
</tr>
{/* row 3 */}
<tr>
<th>User</th>
<td>{systemInfo?.data?.user}</td>
</tr>
<tr>
<th>Architecture</th>
<td>{systemInfo?.data?.arch}</td>
</tr>
<tr>
<th>System</th>
<td>{systemInfo?.data?.platform}</td>
</tr>
<tr>
<th>Hostname</th>
<td>{systemInfo?.data?.hostname}</td>
</tr>
<tr>
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Sizes</th>
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</table>;
}

View file

@ -7,17 +7,18 @@ import
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames";
import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react";
import { Key, Link, Lock, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react";
import
{
useEffect,
useRef,
} from "react";
import { RPC_URL } from "../../../shared/constants";
import { RPC_URL } from "@shared/constants";
import
{
getCurrentUserApiUsersMeGetOptions,
statsApiStatsGetOptions,
} from "../../../clients/romm/@tanstack/react-query.gen";
} from "@clients/romm/@tanstack/react-query.gen";
import toast from "react-hot-toast";
import z from "zod";
import { OptionSpace } from "../../components/options/OptionSpace";
@ -26,20 +27,95 @@ import { rommApi, settingsApi } from "../../scripts/clientApi";
import { Button } from "../../components/options/Button";
import { ContextDialog } from "@/mainview/components/ContextDialog";
import QRCode from "react-qr-code";
import { useAsyncGenerator } from "@/mainview/scripts/utils";
import { useJobStatus } from "@/mainview/scripts/utils";
import { useInterval } from "usehooks-ts";
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
});
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; })
{
const progressRef = useRef<HTMLProgressElement>(null);
useInterval(() =>
{
if (progressRef.current)
{
const time = data.endsAt.getTime() - data.startedAt.getTime();
progressRef.current.value = ((data.endsAt.getTime() - new Date().getTime()) / time) * 100;
}
}, 1000);
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
<QRCode value={data.url} />
<progress ref={progressRef} className="progress w-56" max="100"></progress>
{!!data.code && <p> Code: {data.code} </p>}
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
</ContextDialog>;
}
function TwitchLogin (data: {})
{
const loginStatus = useQuery({
queryKey: ['twitch', 'login', 'status'],
retry (failureCount, error)
{
if (error.status === 404)
{
return false;
}
return failureCount < 3;
},
queryFn: async () =>
{
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
if (error) throw { ...error, status };
return data;
}
});
const loginMutation = useMutation({
mutationKey: ['twitch', 'login'],
mutationFn: (openInBrowser: boolean) =>
{
return rommApi.api.romm.login.twitch.post({ openInBrowser });
},
onSuccess: () => loginStatus.refetch()
});
const logoutMutation = useMutation({
mutationKey: ['twitch', 'logout'],
mutationFn: () =>
{
return rommApi.api.romm.logout.twitch.post();
},
onSuccess: () => loginStatus.refetch()
});
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
return <div className="flex flex-wrap gap-1 items-center justify-center-safe">
{loginStatus.isSuccess ?
<div className="badge badge-success badge-lg rounded-full gap-2"><b>{loginStatus.data.login}</b></div> :
<div className={classNames("badge gap-2 tooltip", { "badge-error": loginStatus.error })} data-tip={loginStatus.error?.message}>
{loginStatus.isError || loginStatus.isRefetchError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
</div>
}
<Button id="twitch-login-btn-qr" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(false)} >
<ScanQrCode />
</Button>
<Button id="twitch-login-btn" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(true)} >
{TwitchIcon}
Login
</Button>
{loginStatus.isSuccess && <Button id="twitch-logout-btn" onAction={() => logoutMutation.mutate()} ><LogOut /> Logout</Button>}
{!!loginData && <LoginQR code={loginData.user_code} url={loginData.url} cancel={() => wsRef.current?.send({ type: 'cancel' })} id='twitch-login-qr' isOpen={true} endsAt={loginData.expires_at} startedAt={loginData.started_at} />}
</div>;
}
function LoginControls (data: { hasPassword: boolean; })
{
const user = useQuery({
@ -48,42 +124,30 @@ function LoginControls (data: { hasPassword: boolean; })
refetchOnWindowFocus: false,
retry: 0
});
const { data: qrLoginStatusGen, refetch } = useQuery({
queryKey: ['login', 'qr'], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.login.remote.status.get();
if (error) throw error;
return data;
}
});
const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]);
const cancelQrMutation = useMutation({
const loginMutation = useMutation({
mutationKey: ['login', 'qr', 'cancel'],
mutationFn: () => rommApi.api.romm.login.remote.cancel.post(),
onSuccess: () => refetch()
});
const requestQrLoginMutation = useMutation({
mutationKey: ['login', 'qr'],
mutationFn: () => rommApi.api.romm.login.remote.start.post(),
onSuccess: () => refetch()
mutationFn: () => rommApi.api.romm.login.romm.post()
});
const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job');
const context = useSettingsFormContext({});
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
onSuccess: async (d, v, r, c) =>
{
user.refetch();
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
}
});
return <div className="flex gap-2 items-center flex-wrap">
{user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(user.error as any)?.detail ?? ''}>
<Lock className="size-4" /></div>}
{user.isSuccess && <>
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
</>}
<Button id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
{user.isSuccess ?
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div> :
<div className={classNames("badge gap-2 tooltip", { "badge-error": user.error })} data-tip={user.error?.message}>
{user.isError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
</div>
}
<Button id="qr-login" type="button" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate()}><ScanQrCode /> </Button>
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
<Save /> Save
</Button>
@ -99,11 +163,11 @@ function LoginControls (data: { hasPassword: boolean; })
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
<X /> Cancel
</Button>
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
{!!statusValue && <LoginQR startedAt={statusValue.startedAt} id="qr-login-context" endsAt={statusValue.endsAt} isOpen={true} cancel={() =>
{
setFocus(`qr-login`);
cancelQrMutation.mutate();
}} url={statusValue?.data?.url ?? ''} />}
wsRef.current?.send({ type: 'cancel' });
}} url={statusValue?.url ?? ''} />}
</div>;
}
@ -183,7 +247,7 @@ function RouteComponent ()
return (
<FocusContext.Provider value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">
<ul ref={ref} className="list relative rounded-box gap-2">
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Romm</h3>
@ -218,12 +282,24 @@ function RouteComponent ()
<loginForm.AppField name="password" children={(field) =>
<field.FormOption label={"Romm Password"} icon={<Key />} type="password" placeholder={hasPassword ? '*****' : "Password"} />} />
<loginForm.Subscribe children={(form) =>
<OptionSpace className="justify-end">
<OptionSpace id="login-controls-space" className="justify-end border-0">
<LoginControls hasPassword={hasPassword === true} />
</OptionSpace>} />
</form>
</loginForm.AppForm>
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex gap-2 items-center">
{TwitchIcon}
<h3> Twitch</h3>
</div>
</div>
</ul>
<OptionSpace label={<div className="flex flex-col">
Twitch Login
<small className="text-base-content/40">for IGDB Metadata</small>
</div>} id="twitch-login-space" className="justify-end border-0">
<TwitchLogin />
</OptionSpace>
</FocusContext.Provider>
);
}

View file

@ -15,6 +15,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import FilePicker from '@/mainview/components/FilePicker';
import { dirname } from 'pathe';
import { autoEmulatorsQuery } from '@/mainview/scripts/queries';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
@ -75,7 +76,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
};
return <OptionSpace label={"Custom Emulator Path"}>
return <OptionSpace id={'custom-emulator-path-option'} label={"Custom Emulator Path"}>
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator
<ChevronDown />
@ -155,7 +156,7 @@ function EmulatorPath (data: { id: string; })
};
return (
<OptionSpace label={
<OptionSpace id={`${data.id}-space`} label={
focus => <>
<p className='font-semibold'>{data.id}</p>
<small className='opacity-40'>{emulators[data.id]}</small>
@ -211,6 +212,7 @@ function EmulatorBadge (data: {
path?: string,
exists: boolean,
emulator: string;
isCritical: boolean;
pathCover?: string;
addOverride: (emulator: string) => void;
})
@ -229,16 +231,16 @@ function EmulatorBadge (data: {
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
<div ref={ref} className={
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
classNames({
"bg-base-200": !data.path,
"border-dashed border-base-content/40 border-2": !data.path && !focused,
"border-dashed border-base-content/40 border-2": !data.path && data.isCritical && !focused,
"border-dashed border-accent border-4": focused
}))
}>
<p className='flex gap-2 font-semibold'>
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className={data.isCritical ? 'text-warning' : 'text-base-content/40'} />}
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
{data.emulator}
</p>
@ -249,11 +251,11 @@ function EmulatorBadge (data: {
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
{
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
{autoEmulators?.map(e => <EmulatorBadge key={e.emulator} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path?.path} exists={e.exists} emulator={e.emulator} />)}
</FocusContext>
</div>;
}

View file

@ -17,6 +17,7 @@ function RouteComponent ()
return <ul ref={ref} className="list rounded-box gap-2">
<FocusContext value={focusKey}>
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption>
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption>
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
</FocusContext>
</ul>;

View file

@ -7,7 +7,7 @@ import
{
Outlet,
createFileRoute,
useMatchRoute,
useMatch,
useNavigate,
} from "@tanstack/react-router";
import { ViewTransitionOptions } from "@tanstack/router-core";
@ -29,7 +29,6 @@ import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import useActiveControl from "@/mainview/scripts/gamepads";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
@ -49,10 +48,13 @@ function MenuItem (data: {
label: string;
})
{
const matchRoute = useMatchRoute();
const navigate = useNavigate();
const acitve = matchRoute({ to: data.route });
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
const handleNonFocusSelect = () =>
{
const { to, search } = PopSource('settings');
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
};
const { ref, focusSelf, focused } = useFocusable({
focusKey: `menu-item-${data.route}`,
forceFocus: !!acitve,
@ -69,29 +71,26 @@ function MenuItem (data: {
? handleNonFocusSelect
: undefined,
});
const { isPointer } = useActiveControl();
return (
<li
ref={ref}
key={data.route}
onClick={data.focusSelect ? focusSelf : handleNonFocusSelect}
onFocus={focusSelf}
className={data.className}
className={twMerge("flex group-focusable cursor-pointer", data.className)}
>
<div
aria-selected={!!acitve}
className={twMerge(
"group rounded-full p-3 md:pl-5 text-base-content/80",
"rounded-full p-3 md:pl-5 text-base-content/80 focusable focusable-accent in-focused:font-semibold aria-selected:bg-primary aria-selected:text-primary-content w-full hover:bg-primary/40 active:bg-base-content active:text-base-100",
classNames({
"bg-primary text-primary-content": acitve,
"font-semibold sm:ring-4 md:ring-7 ring-accent": focused && !isPointer,
"bg-secondary text-secondary-content ring-primary": data.return && focused,
"in-focused:bg-secondary in-focused:text-secondary-content in-focused:ring-primary": data.return,
}),
data.linkClassName,
)}
>
<div className={twMerge("flex gap-2 items-center transition-all", classNames({
"scale-110": focused || acitve
}))}>
<div className="flex gap-2 items-center transition-all in-focused:scale-110">
{data.icon}
<div className="sm:hidden md:inline">{data.label}</div>
</div>
@ -110,7 +109,7 @@ function SettingsMenu (data: {})
return <ul
ref={ref}
className="menu portrait:menu-horizontal md:menu-xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
className="flex flex-col portrait:flex-row md:text-2xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
>
<FocusContext value={focusKey}>
<MenuItem
@ -158,12 +157,12 @@ function SettingsMenu (data: {})
function HandleGoBack ()
{
const source = PopSource('settings');
if (source)
const { to, search } = PopSource('settings');
if (to)
{
console.log("Found source ", source, " to go back to");
console.log("Found source ", to, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search });
}
@ -184,7 +183,7 @@ export function SettingsUI ()
return (
<FocusContext.Provider value={focusKey}>
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full md:p-4">
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full sm:p-2 md:p-4">
<div className="flex landscape:flex-row portrait:flex-col-reverse grow overflow-hidden">
<div id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56">
<SettingsMenu />

View file

@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import
{
useFocusable,
FocusContext,
setFocus,
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import { Router } from "@/mainview";
import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { PopSource } from "@/mainview/scripts/spatialNavigation";
import { systemApi } from "@/mainview/scripts/clientApi";
import { storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@/mainview/scripts/queries";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, Download, Info, Settings } from "lucide-react";
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots";
import { HeaderUI } from "@/mainview/components/Header";
import { useQuery } from "@tanstack/react-query";
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,
async loader (ctx)
{
const emulator = await ctx.context.queryClient.fetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
return { emulator };
}
});
function HomePageLink (data: { homepage: string; })
{
const { ref } = useFocusable({ focusKey: 'homepage-link' });
return <a ref={ref} className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1" onClick={() => systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}</a>;
}
function TitleArea (data: { emulator: FrontEndEmulator; })
{
const [installOpen, setInstallOpen] = useState(false);
const installOptions: DialogEntry[] = [];
const { ref, focusKey } = useFocusable({
focusKey: 'title-area',
preferredChildFocusKey: "install-btn",
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
});
return <div ref={ref} className="flex flex-wrap gap-4 items-center">
<FocusContext value={focusKey}>
<img className="size-32" src={data.emulator.logo}></img>
<div className="flex flex-col grow justify-start gap-1">
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
<p className="flex gap-2">
{data.emulator.systems.map(({ id, name, icon }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
</div>;
})}
</p>
<div className="flex pt-2 gap-1">
<HomePageLink homepage={data.emulator.homepage} />
</div>
</div>
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
data.emulator.exists ?
<><Settings /> Options</> :
<><Download />Install</>
}
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
<ChevronDown />
</Button>
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
{
setInstallOpen(false);
setFocus("install-btn");
}}>
<ContextList options={installOptions}>
</ContextList>
</ContextDialog>
</FocusContext>
</div>;
}
function Description (data: { emulator: FrontEndEmulator; })
{
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
<p>{data.emulator.description}</p>
</div>;
}
export function RouteComponent ()
{
const { id } = Route.useParams();
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: `GAME_DETAIL_${id}`,
trackChildren: true,
preferredChildFocusKey: 'title-area'
});
const { emulator } = Route.useLoaderData();
const { data: recommended } = useQuery(storeEmulatorsRecommendedQuery);
useShortcuts(focusKey, () => [{
label: "Return",
action: () =>
{
const { to, search } = PopSource('store-details');
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
},
button: GamePadButtonCode.B
}]);
useEffect(() =>
{
focusSelf();
}, []);
const { shortcuts } = useShortcutContext();
useStickyDataAttr(headerRef, sentinelRef, ref);
return (
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
<FocusContext.Provider value={focusKey}>
<div className="flex flex-col min-h-full z-10">
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
<HeaderUI />
</div>
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} />
</div>
<div className="flex flex-col bg-base-200 pt-4 min-h-0 grow text-lg">
<Screenshots screenshots={emulator.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />
<Description emulator={emulator} />
</div>
<div className='mobile:hidden bg-gradient'></div>
<div className='mobile:hidden bg-noise'></div>
</div>
<div className="flex flex-col bg-base-100 py-4">
<div className="divider"> <Info className="size-12" /> Stats</div>
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
{!!emulator.keywords &&
<li className="flex flex-wrap gap-2 items-center">
<div className="font-semibold">Tags:</div>
<div className="flex flex-wrap gap-2">{emulator.keywords?.map(k => <span className="rounded-full bg-base-200 px-3 py-1">{k}</span>)}</div>
</li>
}
{!!emulator.status.source &&
<li>
<div>Source</div>
<div>{emulator.status.source}</div>
</li>
}
{!!emulator.status.location &&
<li>
<div>Location</div>
<div>{emulator.status.location}</div>
</li>
}
</ul>
<div className="relative mt-16 bg-base-200">
{recommended && <EmulatorsSection
id={`${id}-recommended`}
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<h2 className="font-bold uppercase tracking-widest">
More Emulators
</h2></>}
onFocus={scrollIntoViewHandler({ block: 'center' })}
onSelect={(id, focus) =>
{
setFocus("title-area");
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
}}
emulators={recommended.map(em => ({
name: em.name,
id: em.name,
installed: em.exists,
logo: em.logo,
systems: em.systems
} satisfies ShopFrontEndEmulator))} />}
</div>
</div>
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
<Shortcuts shortcuts={shortcuts} />
</div>
</FocusContext.Provider>
</AnimatedBackground >
);
}

View file

@ -0,0 +1,80 @@
import { storeEmulatorsQuery } from '@/mainview/scripts/queries';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Joystick } from 'lucide-react';
import { useContext, useEffect } from 'react';
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
export const Route = createFileRoute('/store/tab/emulators')({
component: RouteComponent,
pendingComponent: PendingComponent,
async loader ({ context })
{
const emulators = await context.queryClient.fetchQuery(storeEmulatorsQuery);
return { emulators };
},
});
function PendingComponent ()
{
return <section className="px-6 py-4">
<div className="divider text-info">
<Joystick className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Emulators
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 px-4 gap-4 justify-center-safe">
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="skeleton h-36 rounded-2xl" />)}
</div>
</section>;
}
function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "main-area",
preferredChildFocusKey: focus
});
const storeContext = useContext(StoreContext);
const { emulators } = Route.useLoaderData();
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
focusSelf({ instant: true });
}
}, [focus]);
return <>
<section ref={ref} className="px-6 py-4 animate-slide-up">
<FocusContext value={focusKey}>
<div className="divider text-info">
<Joystick className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Emulators
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 md:px-4 gap-4 justify-center-safe">
{emulators && emulators.map((data) => (
<StoreEmulatorCard
id={data.name}
key={data.name}
emulator={data}
onFocus={({ node, details }) => { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }}
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
/>
))}
</div>
</FocusContext>
</section>
</>;
}

View file

@ -0,0 +1,136 @@
import { StoreGameCard } from '@/mainview/components/store/GamesSection';
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Gamepad, Gamepad2, HardDrive, Save } from 'lucide-react';
import { JSX, useContext, useEffect, useRef, useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { StoreContext } from '@/mainview/scripts/contexts';
import { basename, dirname, extname } from 'pathe';
import { rommApi } from '@/mainview/scripts/clientApi';
import { FrontEndGameType, RPC_URL } from '@/shared/constants';
import CardElement from '@/mainview/components/CardElement';
import { FOCUS_KEYS } from '@/mainview/scripts/types';
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useIntersectionObserver } from 'usehooks-ts';
const staleTime = 24 * 60 * 60 * 1000;
export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent,
async loader (ctx)
{
/*const gamesManifest = await ctx.context.queryClient.fetchQuery({
queryKey: ['store-games-manifest'], queryFn: async () =>
{
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json());
return store.tree.filter((e: any) =>
{
if (e.type === 'blob' && e.path !== "featured.json")
{
return true;
}
return false;
}) as [];
}, staleTime
});
return { gamesManifest };*/
},
});
function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
{
const handleAction = (e?: Event) =>
{
data.onAction?.(e);
if (data.lastId && focused)
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
};
const { ref, focusKey, focused } = useFocusable({
focusKey: 'load-more-btn',
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
onEnterPress: handleAction
});
const { ref: intersct } = useIntersectionObserver({
onChange: (isIntersecting, entry) =>
{
if (isIntersecting)
{
handleAction();
}
}
});
return <div ref={(r) =>
{
ref.current = r;
intersct(r);
}} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={handleAction} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
}
function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<{ data: FrontEndGameType[], nextPage: number; }>({
initialPageParam: 0,
queryKey: ['store-games'],
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
queryFn: async (data) =>
{
const pageParam = data.pageParam as number;
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
if (error) throw error;
return { data: games.games, nextPage: pageParam + 1 };
}
});
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
console.log(focus);
focusSelf({ instant: true });
}
}, [focus]);
const handleFocus = (focusKey: string, node: HTMLElement, details: Record<string, any>) =>
{
node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' });
};
return <>
<section ref={ref} className="px-6 py-4 animate-slide-up">
<FocusContext value={focusKey}>
<div className="divider text-accent">
<Gamepad2 className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Games
</h2>
</div>
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[minmax(18rem,min-content)] py-2 md:px-4 gap-4 justify-center-safe">
{data?.pages.flatMap((page) => (
page.data.map((g, i) => <FrontEndGameCard onFocus={handleFocus} key={g.id.id} game={g} index={i} />))
)}
<LoadMoreButton
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
onFocus={handleFocus}
isFetching={isFetchingNextPage}
onAction={() =>
{
if (isFetchingNextPage)
return;
fetchNextPage();
}} />
</div>
</FocusContext>
</section>
</>;
}

View file

@ -0,0 +1,185 @@
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
import { useFocusable, FocusContext, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { MissingEmulatorsSection } from "../../../components/store/MissingEmulatorsSection";
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
import { GamesSection } from "../../../components/store/GamesSection";
import { StatsSection } from "../../../components/store/StatsSection";
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
import { autoEmulatorsQuery, storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@/mainview/scripts/queries';
import { useContext, useEffect, useRef, useState } from 'react';
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
import { StoreContext } from '@/mainview/scripts/contexts';
import { useInterval } from 'usehooks-ts';
import { Button } from '@/mainview/components/options/Button';
import { HardDrive, Search } from 'lucide-react';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent,
pendingComponent: LoadingSkeleton,
errorComponent: ErrorComponent,
loader: async ({ context }) =>
{
const autoEmulators = await context.queryClient.fetchQuery(autoEmulatorsQuery);
const crutialEmulators = autoEmulators?.filter(e => !e.exists && e.isCritical);
const featuredGames = await await context.queryClient.fetchQuery(storeFeaturedGamesQuery);
const recommendedEmulators = await context.queryClient.fetchQuery(storeEmulatorsRecommendedQuery);
return { crutialEmulators, recommendedEmulators, featuredGames };
}
});
function ErrorComponent (data: ErrorComponentProps)
{
return <div className="flex items-center justify-center h-64">
<div role="alert" className="alert alert-error alert-soft max-w-sm">
<span>Failed to load store data.</span>
<p>{data.error.message}</p>
</div>
</div>;
}
// ── Loading skeleton ───────────────────────────────────────────────────────
function LoadingSkeleton ()
{
return (
<div className="flex flex-col gap-6 px-6 py-4 animate-pulse">
{/* Missing section */}
<div className="grid grid-cols-3 gap-3">
{[1, 2, 3].map((i) => <div key={i} className="skeleton h-40 rounded-2xl" />)}
</div>
{/* Emulators */}
<div className="grid grid-cols-6 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="skeleton h-36 rounded-2xl" />)}
</div>
{/* Games */}
<div className="grid grid-cols-4 gap-3">
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-44 rounded-2xl" />)}
</div>
</div>
);
}
function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
{
const [selectedGame, setSelectedGame] = useState(new Date().getSeconds() % data.games.length);
const [nextSwitch, setNextSwitch] = useState(new Date().getTime() + 10000);
const progressRef = useRef<HTMLProgressElement>(null);
const { ref, focusKey } = useFocusable({ focusKey: 'main-featured-area' });
const game = data.games[selectedGame];
useInterval(() =>
{
setSelectedGame(current => (current + 1) % data.games.length);
setNextSwitch(new Date().getTime() + 10000);
}, 10000);
useInterval(() =>
{
var time = (nextSwitch - new Date().getTime()) / 10000;
if (progressRef.current)
progressRef.current.value = time;
}, 10);
const storeContext = useContext(StoreContext);
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`);
previewUrl.searchParams.set('blur', '16');
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable p-4 mt-4 gap-4'>
<FocusContext value={focusKey}>
<div key={selectedGame} className="flex transition-all duration-500 flex-col sm:32 md:h-64 rounded-3xl overflow-hidden shadow-black/5 shadow-xl grow">
<div className='flex relative h-full overflow-hidden'>
<div className='absolute w-full h-full z-0 bg-base-200'>
<img key={selectedGame}
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 z-0 mask-l-from-0'
src={previewUrl.href}
onLoad={(e) =>
{
e.currentTarget.classList.toggle('opacity-0', false);
e.currentTarget.classList.toggle('scale-110', false);
}}
/>
</div>
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
<div className='flex gap-4 max-h-full z-1 grow'>
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
<div className='relative rounded-3xl max-w-xs overflow-hidden'>
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg'><HardDrive /></div>
<img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />
</div>
<div className='flex flex-col gap-2 py-3 max-w-md'>
<h1 className='font-semibold text-3xl'>{game.name}</h1>
<p className='overflow-hidden text-wrap text-ellipsis text-base-content/60'>{game.summary}</p>
</div>
</div>
</div>
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
</div>
</div>
{data.children}
</div>
<div className='sm:flex sm:flex-wrap grow justify-stretch md:grid sm:landscape:grid-flow-col sm:auto-cols-[minmax(8rem,1fr)] md:grid-flow-row! auto-rows-fr landscape:min-w-xs gap-4'>
{data.games.map((g, i) =>
<div key={i} data-active={i === selectedGame} className='flex grow flex-col gap-1 transition-opacity duration-500 data-[active=true]:opacity-50 rounded-3xl bg-base-100 p-4 justify-center shadow-md'>
<div className='flex gap-2'>
<img className='size-6' src={`${RPC_URL(__HOST__)}${game.path_platform_cover}`}></img>
<div className='flex gap-2 items-center grow'>
{g.name}
</div>
</div>
{i === selectedGame && <progress ref={progressRef} className="progress progress-accent w-full" style={{ animationName: '' }} value={0} max="1"></progress>}
</div>)}
</div>
</FocusContext>
</div>;
}
export function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData();
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
const storeContext = useContext(StoreContext);
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
focusSelf({ instant: true });
}
}, [focus]);
return (
<div className='animate-slide-up' ref={ref}>
<FocusContext value={focusKey}>
{!!featuredGames && <Main games={featuredGames} />}
{crutialEmulators.length > 0 && <MissingEmulatorsSection
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
emulators={crutialEmulators} />}
<div className='pt-4'>
<EmulatorsSection
id="recommended-emulators"
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
onFocus={scrollIntoViewHandler({ block: 'end' })}
emulators={recommendedEmulators} />
</div>
<GamesSection
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
onFocus={scrollIntoViewHandler({ block: 'center' })}
games={featuredGames}
/>
<StatsSection
romCount={1240}
missingCount={crutialEmulators.length}
/>
</FocusContext>
</div>
);
}

View file

@ -0,0 +1,156 @@
import { Router } from '@/mainview';
import { FilterUI } from '@/mainview/components/Filters';
import { HeaderUI } from '@/mainview/components/Header';
import Shortcuts from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
import { SaveSource } from '@/mainview/scripts/spatialNavigation';
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMatchRoute } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { Settings } from 'lucide-react';
import { useEffect, useRef } from 'react';
import z from 'zod';
export const Route = createFileRoute('/store/tab')({
component: RouteComponent,
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
});
function useIsSettings (subPath: string)
{
"use no memo";
const matchRoute = useMatchRoute();
const isSettings = !!matchRoute({
to: `/store/tab/${subPath}` as any
});
return isSettings;
}
function TopArea (data: { filters: Record<string, FilterOption>; })
{
const { ref, focusKey } = useFocusable({
focusKey: 'top-area',
preferredChildFocusKey: 'store-tabs',
onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
}
});
return <div ref={ref}>
<FocusContext value={focusKey}>
<div className='w-full'>
<FilterUI containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters} setSelected={(s) => Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} />
</div>
</FocusContext>
</div>;
}
function RouteComponent ()
{
// Root spatial nav container
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "STORE_ROOT",
trackChildren: true,
preferredChildFocusKey: 'top-area'
});
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const filters: Record<string, FilterOption> = {
home: { label: "Home", selected: useIsSettings(''), },
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
games: { label: "Games", selected: useIsSettings('games') }
};
useShortcuts(focusKey, () => [{
label: "Return",
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
button: GamePadButtonCode.B
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.R1
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.L1
}], [filters]);
const { shortcuts } = useShortcutContext();
const { focus } = Route.useSearch();
useEffect(() =>
{
if (!focus)
{
focusSelf();
}
}, []);
const handleDetails = (type: string, source: string, id: string, focus: string) =>
{
if (type === 'emulator')
{
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
}
else if (type === 'game')
{
console.log(source, id);
SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } });
}
};
const match = Route.useMatch();
const goToSettings = () =>
{
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
};
const isMobile = mobileCheck();
useStickyDataAttr(headerRef, sentinelRef, ref);
return <div ref={ref} className='overflow-y-scroll w-screen h-screen' >
<StoreContext value={{ showDetails: handleDetails }} >
<FocusContext.Provider value={focusKey}>
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
</div>
<TopArea filters={filters} />
<Outlet />
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
<Shortcuts shortcuts={shortcuts} />
</div>
{!isMobile && <>
<div className='bg-gradient'></div>
<div className='bg-noise'></div>
</>}
</div>
</FocusContext.Provider>
</StoreContext>
</div >;
}

View file

@ -0,0 +1,4 @@
export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Twitch</title>
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
</svg>;

View file

@ -1,25 +1,16 @@
import { treaty } from "@elysiajs/eden";
import { RommAPIType, SettingsAPIType, SystemAPIType } from "../../bun/api/rpc";
import { Treaty, treaty } from "@elysiajs/eden";
import { JobsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
import { RPC_URL } from "../../shared/constants";
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
const options: Treaty.Config = {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
};
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), options);
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), options);
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), options);
export const storeApi = treaty<StoreAPIType>(RPC_URL(__HOST__), options);
export const jobsApi = treaty<JobsAPIType>(RPC_URL(__HOST__), options);

View file

@ -0,0 +1,34 @@
import { Drive } from "@/shared/constants";
import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
import { createContext } from "react";
export const StoreContext = createContext({} as {
showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void;
forceFocus?: string;
});
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
export const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
export const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
export const FilePickerContext = createContext<{
allowNewFolderCreation: boolean;
isDirectoryPicker: boolean;
setCurrentPath: (path: string) => void;
currentPath: string | undefined,
startingPath: string | undefined;
refetchFiles: () => void;
drives: Drive[],
activeDrive: Drive | undefined;
}>({} as any);

View file

@ -1,11 +1,12 @@
import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react";
import { mobileCheck } from "./utils";
let loopStarted = false;
let isTouching = false;
type ActiveControlType = 'keyboard' | 'gamepad' | 'mouse' | 'touch' | undefined;
let activeControls: ActiveControlType = undefined;
let activeControls: ActiveControlType = mobileCheck() ? 'touch' : 'mouse';
let mouseUpdateTimeout: any | undefined = undefined;
let touchStopTimeout: any | undefined = undefined;
@ -79,7 +80,7 @@ export default function useActiveControl ()
return () => window.removeEventListener('activecontrolschange', handler);
});
return { isMouse: c === 'mouse', isPointer: c === 'mouse' || c === 'touch', control: c };
return { isMouse: c === 'mouse', isTouch: c === 'touch', isPointer: c === 'mouse' || c === 'touch', control: c };
}
const throttleMap = new Map<string, number>();
@ -97,7 +98,10 @@ function throttleNav (key: string, dir: string, event: Event)
navigateByDirection(dir, { event });
throttleMap.set(key, currentDate.getTime());
throttleAcceleration.set(key, acceleration + 1);
return true;
}
return false;
}
function focusControl (control: typeof activeControls)
@ -214,12 +218,14 @@ function updateStatus ()
{
if (gamepad.axes[0] > deadzone)
{
throttleNav('gpa-right', "right", gamepadEvent);
if (throttleNav('gpa-right', "right", gamepadEvent))
focusControl('gamepad');
return;
}
else if (gamepad.axes[0] < -deadzone)
{
throttleNav('gpa-left', "left", gamepadEvent);
if (throttleNav('gpa-left', "left", gamepadEvent))
focusControl('gamepad');
return;
}
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
@ -232,11 +238,13 @@ function updateStatus ()
if (gamepad.axes[1] > deadzone)
{
throttleNav('gpa-down', "down", gamepadEvent);
if (throttleNav('gpa-down', "down", gamepadEvent))
focusControl('gamepad');
}
else if (gamepad.axes[1] < -deadzone)
{
throttleNav('gpa-up', "up", gamepadEvent);
if (throttleNav('gpa-up', "up", gamepadEvent))
focusControl('gamepad');
} else
{
throttleAcceleration.delete('gpa-up');

View file

@ -1,5 +1,5 @@
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
import { rommApi, settingsApi, systemApi } from "./clientApi";
import { rommApi, settingsApi, storeApi, systemApi } from "./clientApi";
import toast from "react-hot-toast";
import { getErrorMessage } from "react-error-boundary";
@ -52,7 +52,7 @@ export const changeDownloadsMutation = mutationOptions({
}
});
export const gameQuery = (source: string, id: number) => queryOptions({
export const gameQuery = (source: string, id: string) => queryOptions({
queryKey: ['game', source, id],
queryFn: async () =>
{
@ -60,4 +60,49 @@ export const gameQuery = (source: string, id: number) => queryOptions({
if (error) throw error;
return data;
},
});
export const autoEmulatorsQuery = queryOptions({
queryKey: ['auto-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
if (error) throw error;
return data;
}
});
export const storeEmulatorsQuery = queryOptions({
queryKey: ['store-emulators'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get();
if (error) throw error;
return data;
}
});
export const storeFeaturedGamesQuery = queryOptions({
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.games.featured.get();
if (error) throw error;
return data;
}
});
export const storeEmulatorsRecommendedQuery = queryOptions({
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
if (error) throw error;
return data;
}
});
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
queryKey: ['store-emulator', id], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
if (error) throw error;
return data;
}
});

View file

@ -51,6 +51,7 @@ function markDirtyThrottled ()
window.addEventListener('focuschanged', markDirtyThrottled);
import.meta.hot?.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
import.meta.hot?.dispose(() => shortcutMap.clear());
export function useShortcutContext ()
{
@ -81,6 +82,12 @@ export function useShortcutContext ()
const handleGamepadButtonDown = (e: Event) =>
{
const event = e as GamepadButtonEvent;
if (event.button == GamePadButtonCode.B && document.fullscreenElement)
{
document.exitFullscreen();
return;
}
if (shortcuts.has(event.button))
{
shortcuts.get(event.button)?.action?.(event);
@ -166,6 +173,7 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
return () =>
{
shortcutMap.delete(focusKey);
markDirtyThrottled();
};
}, [...deps, focusKey]);

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