SettingsRouteRoute,
} as any)
+const SettingsTasksRoute = SettingsTasksRouteImport.update({
+ id: '/tasks',
+ path: '/tasks',
+ getParentRoute: () => SettingsRouteRoute,
+} as any)
const SettingsPluginsRoute = SettingsPluginsRouteImport.update({
id: '/plugins',
path: '/plugins',
@@ -81,6 +93,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({
path: '/about',
getParentRoute: () => SettingsRouteRoute,
} as any)
+const GameAddRoute = GameAddRouteImport.update({
+ id: '/game/add',
+ path: '/game/add',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StoreTabRouteRoute = StoreTabRouteRouteImport.update({
id: '/store/tab',
path: '/store/tab',
@@ -91,6 +108,11 @@ const StoreTabIndexRoute = StoreTabIndexRouteImport.update({
path: '/',
getParentRoute: () => StoreTabRouteRoute,
} as any)
+const StoreTabPluginsRoute = StoreTabPluginsRouteImport.update({
+ id: '/plugins',
+ path: '/plugins',
+ getParentRoute: () => StoreTabRouteRoute,
+} as any)
const StoreTabGamesRoute = StoreTabGamesRouteImport.update({
id: '/games',
path: '/games',
@@ -101,6 +123,11 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
path: '/emulators',
getParentRoute: () => StoreTabRouteRoute,
} as any)
+const StoreTabDownloadRoute = StoreTabDownloadRouteImport.update({
+ id: '/download',
+ path: '/download',
+ getParentRoute: () => StoreTabRouteRoute,
+} as any)
const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({
id: '/plugin/$source',
path: '/plugin/$source',
@@ -131,23 +158,41 @@ const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({
path: '/collection/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
+const StoreDetailsPluginIdRoute = StoreDetailsPluginIdRouteImport.update({
+ id: '/store/details/plugin/$id',
+ path: '/store/details/plugin/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
id: '/store/details/emulator/$id',
path: '/store/details/emulator/$id',
getParentRoute: () => rootRouteImport,
} as any)
+const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({
+ id: '/game/update/$source/$id',
+ path: '/game/update/$source/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const StoreDetailsDownloadSourceIdRoute =
+ StoreDetailsDownloadSourceIdRouteImport.update({
+ id: '/store/details/download/$source/$id',
+ path: '/store/details/download/$source/$id',
+ getParentRoute: () => rootRouteImport,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute
'/store/tab': typeof StoreTabRouteRouteWithChildren
+ '/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
+ '/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@@ -155,21 +200,28 @@ export interface FileRoutesByFullPath {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
+ '/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
+ '/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab/': typeof StoreTabIndexRoute
+ '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
+ '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
+ '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute
+ '/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
+ '/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@@ -177,10 +229,15 @@ export interface FileRoutesByTo {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
+ '/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
+ '/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab': typeof StoreTabIndexRoute
+ '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
+ '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
+ '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -188,12 +245,14 @@ export interface FileRoutesById {
'/settings': typeof SettingsRouteRouteWithChildren
'/games': typeof GamesRoute
'/store/tab': typeof StoreTabRouteRouteWithChildren
+ '/game/add': typeof GameAddRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
+ '/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@@ -201,10 +260,15 @@ export interface FileRoutesById {
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
+ '/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute
+ '/store/tab/plugins': typeof StoreTabPluginsRoute
'/store/tab/': typeof StoreTabIndexRoute
+ '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
+ '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
+ '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -213,12 +277,14 @@ export interface FileRouteTypes {
| '/settings'
| '/games'
| '/store/tab'
+ | '/game/add'
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
+ | '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@@ -226,21 +292,28 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
+ | '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
+ | '/store/tab/plugins'
| '/store/tab/'
+ | '/game/update/$source/$id'
| '/store/details/emulator/$id'
+ | '/store/details/plugin/$id'
+ | '/store/details/download/$source/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/settings'
| '/games'
+ | '/game/add'
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
+ | '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@@ -248,22 +321,29 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
+ | '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
+ | '/store/tab/plugins'
| '/store/tab'
+ | '/game/update/$source/$id'
| '/store/details/emulator/$id'
+ | '/store/details/plugin/$id'
+ | '/store/details/download/$source/$id'
id:
| '__root__'
| '/'
| '/settings'
| '/games'
| '/store/tab'
+ | '/game/add'
| '/settings/about'
| '/settings/accounts'
| '/settings/directories'
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
+ | '/settings/tasks'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
@@ -271,10 +351,15 @@ export interface FileRouteTypes {
| '/launcher/$source/$id'
| '/platform/$source/$id'
| '/settings/plugin/$source'
+ | '/store/tab/download'
| '/store/tab/emulators'
| '/store/tab/games'
+ | '/store/tab/plugins'
| '/store/tab/'
+ | '/game/update/$source/$id'
| '/store/details/emulator/$id'
+ | '/store/details/plugin/$id'
+ | '/store/details/download/$source/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -282,12 +367,16 @@ export interface RootRouteChildren {
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
GamesRoute: typeof GamesRoute
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
+ GameAddRoute: typeof GameAddRoute
CollectionSourceIdRoute: typeof CollectionSourceIdRoute
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
GameSourceIdRoute: typeof GameSourceIdRoute
LauncherSourceIdRoute: typeof LauncherSourceIdRoute
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
+ GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
+ StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute
+ StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute
}
declare module '@tanstack/react-router' {
@@ -320,6 +409,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsUpdateRouteImport
parentRoute: typeof SettingsRouteRoute
}
+ '/settings/tasks': {
+ id: '/settings/tasks'
+ path: '/tasks'
+ fullPath: '/settings/tasks'
+ preLoaderRoute: typeof SettingsTasksRouteImport
+ parentRoute: typeof SettingsRouteRoute
+ }
'/settings/plugins': {
id: '/settings/plugins'
path: '/plugins'
@@ -362,6 +458,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof SettingsRouteRoute
}
+ '/game/add': {
+ id: '/game/add'
+ path: '/game/add'
+ fullPath: '/game/add'
+ preLoaderRoute: typeof GameAddRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/store/tab': {
id: '/store/tab'
path: '/store/tab'
@@ -376,6 +479,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreTabIndexRouteImport
parentRoute: typeof StoreTabRouteRoute
}
+ '/store/tab/plugins': {
+ id: '/store/tab/plugins'
+ path: '/plugins'
+ fullPath: '/store/tab/plugins'
+ preLoaderRoute: typeof StoreTabPluginsRouteImport
+ parentRoute: typeof StoreTabRouteRoute
+ }
'/store/tab/games': {
id: '/store/tab/games'
path: '/games'
@@ -390,6 +500,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
parentRoute: typeof StoreTabRouteRoute
}
+ '/store/tab/download': {
+ id: '/store/tab/download'
+ path: '/download'
+ fullPath: '/store/tab/download'
+ preLoaderRoute: typeof StoreTabDownloadRouteImport
+ parentRoute: typeof StoreTabRouteRoute
+ }
'/settings/plugin/$source': {
id: '/settings/plugin/$source'
path: '/plugin/$source'
@@ -432,6 +549,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CollectionSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/store/details/plugin/$id': {
+ id: '/store/details/plugin/$id'
+ path: '/store/details/plugin/$id'
+ fullPath: '/store/details/plugin/$id'
+ preLoaderRoute: typeof StoreDetailsPluginIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/store/details/emulator/$id': {
id: '/store/details/emulator/$id'
path: '/store/details/emulator/$id'
@@ -439,6 +563,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/game/update/$source/$id': {
+ id: '/game/update/$source/$id'
+ path: '/game/update/$source/$id'
+ fullPath: '/game/update/$source/$id'
+ preLoaderRoute: typeof GameUpdateSourceIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/store/details/download/$source/$id': {
+ id: '/store/details/download/$source/$id'
+ path: '/store/details/download/$source/$id'
+ fullPath: '/store/details/download/$source/$id'
+ preLoaderRoute: typeof StoreDetailsDownloadSourceIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -449,6 +587,7 @@ interface SettingsRouteRouteChildren {
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
SettingsPluginsRoute: typeof SettingsPluginsRoute
+ SettingsTasksRoute: typeof SettingsTasksRoute
SettingsUpdateRoute: typeof SettingsUpdateRoute
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
}
@@ -460,6 +599,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute,
SettingsPluginsRoute: SettingsPluginsRoute,
+ SettingsTasksRoute: SettingsTasksRoute,
SettingsUpdateRoute: SettingsUpdateRoute,
SettingsPluginSourceRoute: SettingsPluginSourceRoute,
}
@@ -469,14 +609,18 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
)
interface StoreTabRouteRouteChildren {
+ StoreTabDownloadRoute: typeof StoreTabDownloadRoute
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
StoreTabGamesRoute: typeof StoreTabGamesRoute
+ StoreTabPluginsRoute: typeof StoreTabPluginsRoute
StoreTabIndexRoute: typeof StoreTabIndexRoute
}
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
+ StoreTabDownloadRoute: StoreTabDownloadRoute,
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
StoreTabGamesRoute: StoreTabGamesRoute,
+ StoreTabPluginsRoute: StoreTabPluginsRoute,
StoreTabIndexRoute: StoreTabIndexRoute,
}
@@ -489,12 +633,16 @@ const rootRouteChildren: RootRouteChildren = {
SettingsRouteRoute: SettingsRouteRouteWithChildren,
GamesRoute: GamesRoute,
StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
+ GameAddRoute: GameAddRoute,
CollectionSourceIdRoute: CollectionSourceIdRoute,
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
GameSourceIdRoute: GameSourceIdRoute,
LauncherSourceIdRoute: LauncherSourceIdRoute,
PlatformSourceIdRoute: PlatformSourceIdRoute,
+ GameUpdateSourceIdRoute: GameUpdateSourceIdRoute,
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
+ StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute,
+ StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/src/mainview/index.css b/src/mainview/index.css
index 4c82b71..332862e 100644
--- a/src/mainview/index.css
+++ b/src/mainview/index.css
@@ -9,6 +9,7 @@
@theme {
--breakpoint-sm: 0px;
--breakpoint-md: 1024px;
+ --breakpoint-lg: 1280px;
--page-scroll-bg: transparent;
--animation-size: 1;
diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx
index f5639f9..166cc4f 100644
--- a/src/mainview/index.tsx
+++ b/src/mainview/index.tsx
@@ -8,7 +8,7 @@ import
RouterProvider,
} from "@tanstack/react-router";
import { routeTree } from "./gen/routeTree.gen";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClient } from "@tanstack/react-query";
import "./scripts/gamepads";
import "./scripts/windowEvents";
import "./scripts/spatialNavigation";
@@ -16,6 +16,16 @@ import NotFound from "./components/NotFound";
import Error from "./components/Error";
import serviceWorker from './scripts/serviceWorker?worker&url';
import App from "./App";
+import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
+import { createStore, get, set, del } from "idb-keyval";
+import
+{
+ PersistedClient,
+ Persister,
+} from '@tanstack/react-query-persist-client';
+import pkg from '../../package.json';
+
+const idbStore = createStore("tanstack-query", "cache");
if ('serviceWorker' in navigator)
{
@@ -24,7 +34,31 @@ if ('serviceWorker' in navigator)
const hashHistory = createHashHistory({});
-const queryClient = new QueryClient();
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: 1000 * 60 * 60 * 24 * 5, // 5 days
+ }
+ }
+});
+
+export function createIDBPersister (idbValidKey: IDBValidKey = 'reactQuery'): Persister
+{
+ return {
+ persistClient: async (client: PersistedClient) =>
+ {
+ await set(idbValidKey, client, idbStore);
+ },
+ restoreClient: async () =>
+ {
+ return await get
(idbValidKey, idbStore);
+ },
+ removeClient: async () =>
+ {
+ await del(idbValidKey, idbStore);
+ },
+ } satisfies Persister;
+}
export interface RouterContext
{
@@ -74,9 +108,9 @@ if (!rootElement.innerHTML)
root.render(
-
+
-
+
,
);
diff --git a/src/mainview/query-options.ts b/src/mainview/query-options.ts
index a52c649..879d632 100644
--- a/src/mainview/query-options.ts
+++ b/src/mainview/query-options.ts
@@ -1,6 +1,7 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getRomApiRomsIdGetOptions, getRomsApiRomsGetOptions } from "../clients/romm/@tanstack/react-query.gen";
-import { DefaultRommStaleTime, GameListFilterType } from "../shared/constants";
+import { DefaultRommStaleTime } from "../shared/constants";
+import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
export function gamesQueryOptions (filter?: GameListFilterType)
{
diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx
index fbe2f26..cafbab4 100644
--- a/src/mainview/routes/__root.tsx
+++ b/src/mainview/routes/__root.tsx
@@ -8,6 +8,7 @@ import { useEffect } from "react";
import AppCommunication from "../components/AppCommunication";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
+import GlobalContextDialog from "../components/GlobalContextDialog";
export const Route = createRootRouteWithContext()({
component: RootComponent,
@@ -39,9 +40,11 @@ function RootComponent ()
return (
-
-
-
+
+
+
+
+
{queryDevOptions &&
}
diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx
index 3b73d25..a08c164 100644
--- a/src/mainview/routes/collection.$source.$id.tsx
+++ b/src/mainview/routes/collection.$source.$id.tsx
@@ -6,7 +6,7 @@ import { AnimatedBackgroundContext } from '../scripts/contexts';
import { getCollectionQuery } from '@queries/romm';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
-import { GameListFilterType } from '@/shared/constants';
+import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { useLocalStorage } from 'usehooks-ts';
export const Route = createFileRoute('/collection/$source/$id')({
diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx
index 3222280..b9a39b6 100644
--- a/src/mainview/routes/embedded.$source.$id.tsx
+++ b/src/mainview/routes/embedded.$source.$id.tsx
@@ -5,7 +5,7 @@ import z from 'zod';
import { RefObject, useEffect, useRef, useState } from 'react';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { ButtonStyle } from '../components/options/Button';
-import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react';
+import { CloudDownload, DoorOpen, RefreshCw, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { FloatingShortcuts } from '../components/Shortcuts';
import { useEventListener } from 'usehooks-ts';
diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx
index d0a346e..13ea8ac 100644
--- a/src/mainview/routes/game/$source.$id.tsx
+++ b/src/mainview/routes/game/$source.$id.tsx
@@ -6,8 +6,8 @@ import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "
import { HeaderUI, StickyHeaderUI } from "../../components/Header";
import { AnimatedBackground } from "../../components/AnimatedBackground";
import { useQuery } from "@tanstack/react-query";
-import Shortcuts, { FloatingShortcuts } from "../../components/Shortcuts";
-import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
+import { FloatingShortcuts } from "../../components/Shortcuts";
+import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import Screenshots from "@/mainview/components/Screenshots";
import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils";
import { FilterUI } from "@/mainview/components/Filters";
@@ -23,7 +23,8 @@ import { GamesSection } from "@/mainview/components/store/GamesSection";
import Details from "@/mainview/components/game/Details";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import SelectMenu from "@/mainview/components/SelectMenu";
-import { en } from "zod/v4/locales";
+import { IGDBIcon } from "@/mainview/scripts/brandIcons";
+import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared";
export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) =>
@@ -32,7 +33,9 @@ export const Route = createFileRoute("/game/$source/$id")({
},
component: RouteComponent,
errorComponent: Error,
- validateSearch: zodValidator(z.object({ focus: z.string().optional() })),
+ validateSearch: zodValidator(z.object({
+ focus: z.string().optional(),
+ })),
staticData: {
enterSound: 'openDetails',
goBackSound: "returnDetails"
@@ -105,6 +108,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon:
});
if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
+ if (data.game.igdb_id)
+ stats.push({ label: "IGDB", icon: IGDBIcon, content: String(data.game.igdb_id) });
if (data.game.source)
stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` });
const integrations = new Set
(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c));
diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx
new file mode 100644
index 0000000..6399cd0
--- /dev/null
+++ b/src/mainview/routes/game/add.tsx
@@ -0,0 +1,425 @@
+import { AutoFocus } from '@/mainview/components/AutoFocus';
+import { OptionElement } from '@/mainview/components/ContextDialog';
+import GameLookupElement from '@/mainview/components/game/GameLookup';
+import { StickyHeaderUI } from '@/mainview/components/Header';
+import LoadingScreen from '@/mainview/components/LoadingScreen';
+import { Button } from '@/mainview/components/options/Button';
+import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption';
+import SelectMenu from '@/mainview/components/SelectMenu';
+import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
+import { oneShot } from '@/mainview/scripts/audio/audio';
+import { rommApi } from '@/mainview/scripts/clientApi';
+import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm';
+import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
+import { HandleGoBack } from '@/mainview/scripts/utils';
+import { isUrl } from '@/shared/utils';
+import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
+import { zodValidator } from '@tanstack/zod-adapter';
+import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, File, FileSearch, FolderOpen, Globe, HardDrive, Link, Save } from 'lucide-react';
+import { basename } from 'pathe';
+import prettyBytes from 'pretty-bytes';
+import { JSX, useState } from 'react';
+import toast from 'react-hot-toast';
+import { twMerge } from 'tailwind-merge';
+import z from 'zod';
+
+
+const StateSchema = z.object({
+ step: z.number().default(0),
+ gameLocation: z.string().optional(),
+ selectedGame: z.object({ source: z.string(), id: z.string() }).optional(),
+ platformId: z.number().optional(),
+ search: z.string().optional()
+});
+
+export const Route = createFileRoute('/game/add')({
+ component: RouteComponent,
+ validateSearch: zodValidator(StateSchema)
+});
+
+function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; })
+{
+ const [localLocation, setLocalLocation] = useState(data.location);
+ const navigate = useNavigate();
+ return
+
+ ;
+}
+
+const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g;
+const EXTENSION_REGEX = /\.(([a-z]+\.)*\w+)$/g;
+const LEADING_ARTICLE_PATTERN = /^(a|an|the)\b/g;
+const COMMA_ARTICLE_PATTERN = /,\s(a|an|the)\b(?=\s*[^\w\s]|$)/g;
+const NON_WORD_SPACE_PATTERN = /[^\w\s]/g;
+const MULTIPLE_SPACE_PATTERN = /\s+/g;
+
+function BuildSearch (filePath: string)
+{
+ const name = basename(filePath);
+ const nameWithoutExt = name.replace(EXTENSION_REGEX, "").trim();
+ if (!nameWithoutExt) return undefined;
+ const nameWithoutTags = nameWithoutExt.replaceAll(TAG_REGEX, "").trim();
+ if (TAG_REGEX.test(nameWithoutExt)) console.log("match");
+ if (!nameWithoutTags) return undefined;
+
+ // Lower and replace underscores with spaces
+ let finalSearch = nameWithoutTags.toLowerCase().replace("_", " ");
+
+ // Remove articles (combined if possible)
+ finalSearch = finalSearch.replaceAll(LEADING_ARTICLE_PATTERN, '');
+ finalSearch = finalSearch.replaceAll(COMMA_ARTICLE_PATTERN, '');
+
+ // Remove punctuation and normalize spaces in one step
+ finalSearch = finalSearch.replaceAll(NON_WORD_SPACE_PATTERN, '');
+ finalSearch = finalSearch.replaceAll(MULTIPLE_SPACE_PATTERN, '');
+
+ return nameWithoutTags;
+}
+
+const typeIconMap: Record = {
+ new: ,
+ existing: ,
+ unknown:
+};
+
+function Overview (data: {})
+{
+ const navigate = useNavigate();
+ const router = useRouter();
+ const state = Route.useSearch();
+ const linkInfo = useQuery({
+ enabled (query)
+ {
+ return isUrl(query.queryKey[1]);
+ },
+ queryKey: ['dl-link-info', state.gameLocation],
+ queryFn: async () =>
+ {
+ return rommApi.api.romm.download.file.info.get({ query: { file_url: state.gameLocation! } });
+ }
+ });
+ const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id));
+ const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId));
+ const addGame = useMutation({
+ ...addManualGameMutation,
+ onError (error, variables, onMutateResult, context)
+ {
+ toast.error(error.message);
+ },
+ async onSuccess (data, variables, onMutateResult, context)
+ {
+ if (data.id === null || isUrl(state.gameLocation)) return;
+ await context.client.invalidateQueries(allGamesInvalidateQuery);
+ navigate({
+ to: '/game/$source/$id', params: {
+ source: data.source, id: String(data.id)
+ }, replace: true
+ });
+ },
+ });
+
+ if (!game) return Select A Game
;
+
+ return
+
Preview
+
+
{!!game[0].coverUrl &&

}
+
+
{game[0].name}
+
{game[0].summary}
+
+
{platform?.details.name}
+
+
+ {!!platform?.match.coverUrl &&

}
+
{platform?.match.name}
+
{platform?.match.family_name}
+
+ {!!platform?.match.type && typeIconMap[platform?.match.type]}
+
{platform?.match.type}
+
+
+
{isUrl(state.gameLocation) ? : }{state.gameLocation}
+
+
+ {linkInfo.isFetching ? : (linkInfo.data?.data?.size && prettyBytes(linkInfo.data.data.size))}
+
+ {linkInfo.isFetching ? : (linkInfo.data?.data?.content_type && linkInfo.data.data.content_type)}
+
+
+
+
Actions
+
+
+
+
+
;
+}
+
+function PlatformEntry (data: {
+ id: string,
+ displayName: string,
+ platformSource: string,
+ platformId: number;
+})
+{
+ const state = Route.useSearch();
+ const { data: match, isFetching: matchIsFetching } = useQuery({ ...platformLookupMatchQuery(data.platformSource, data.platformId), staleTime: 1000 * 60 * 60 });
+ const navigate = useNavigate();
+ const handleAction = () =>
+ {
+ navigate({ to: '/game/add', search: { ...state, platformId: data.platformId, step: 3 }, replace: true });
+ oneShot('openGeneric');
+ };
+
+ return
+ {data.displayName}
+
+ {matchIsFetching ? : match && <>
+
+ {match.match.coverUrl ?
: }
+ {match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}
+ >}
+
+ } type={'primary'} />;
+}
+
+function PlatformSelection (data: {})
+{
+ const state = Route.useSearch();
+ const { data: game, isFetching } = useQuery({ ...gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id), staleTime: 1000 * 60 * 60 });
+ if (isFetching) return ;
+ if (!game) return Select A Game
;
+ return
+ {game[0].platforms.map((p, i) => )}
+
;
+}
+
+function Lookup ()
+{
+ const state = Route.useSearch();
+ const [search, setSearch] = useState(state.search);
+ const navigate = useNavigate();
+ const handleSetSelectedGame = (source: string, id: string) =>
+ {
+ navigate({ to: '/game/add', search: { ...state, selectedGame: { source, id }, platformId: undefined, search, step: 2 }, replace: true });
+ oneShot('openGeneric');
+ };
+ return
+ {
+ handleSetSelectedGame(l.source, l.id);
+ }} />;
+}
+
+const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { label: "Select Platform" }, { label: "Confirm" }];
+
+function Location ()
+{
+ const state = Route.useSearch();
+ const navigate = useNavigate();
+ const handleSetLocation = (location: string | undefined) =>
+ {
+ if (!location) return;
+ navigate({
+ to: '/game/add', search: {
+ ...state,
+ gameLocation: location,
+ search: BuildSearch(location),
+ selectedGame: undefined,
+ platformId: undefined,
+ step: 1
+ }, replace: true
+ });
+ oneShot('openGeneric');
+ };
+ return
+
Select Game Rom
+
+
+ Select The Rom File from your local storage or use a link
+
+
;
+}
+
+function Details (data: {})
+{
+ const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' });
+ const state = Route.useSearch();
+ const step = state.step ?? 0;
+ return
+
+ {step === 0 && }
+ {step === 1 && }
+ {step === 2 && }
+ {step === 3 && }
+
+
+
;
+}
+
+function getStepDetails (index: number, state: z.infer)
+{
+ let completed = index < state.step;
+ if (index === 0 && state.gameLocation) completed = true;
+ if (index === 1 && state.selectedGame) completed = true;
+ if (index === 2 && state.platformId) completed = true;
+ if (index === 3 && state.gameLocation && state.selectedGame && state.platformId) completed = true;
+ let canNavigate = index <= state.step;
+ if (index === 1 && state.gameLocation) canNavigate = true;
+ if (index === 2 && state.selectedGame) canNavigate = true;
+ if (index === 3 && state.platformId) canNavigate = true;
+ return { completed, canNavigate };
+}
+
+function Step (data: { index: number; label: string; })
+{
+ const navigate = useNavigate();
+ const handleGoToStep = (step: number) =>
+ {
+ navigate({ to: '/game/add', search: { ...state, step: step }, replace: true });
+ oneShot('openGeneric');
+ };
+ const state = Route.useSearch();
+ const step = state.step ?? 0;
+ const { canNavigate, completed } = getStepDetails(data.index, state);
+
+ const { ref } = useFocusable({
+ focusKey: `step-${data.index}`,
+ focusable: canNavigate,
+ onFocus: () =>
+ {
+ if (step === data.index) return;
+ navigate({ to: '/game/add', search: { ...state, step: data.index }, replace: true });
+ oneShot('openGeneric');
+ }
+ });
+ return
+ {
+ if (!canNavigate) return;
+ handleGoToStep(data.index);
+ }} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}>
+ {completed ? : }
+ {data.label}
+ ;
+}
+
+function Steps ()
+{
+ const state = Route.useSearch();
+ const step = state.step ?? 0;
+ const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false });
+ return
+
+
+
+ {StepDetails.map((s, i) => )}
+
+
;
+}
+
+function RouteComponent ()
+{
+ const navigate = useNavigate();
+ const state = Route.useSearch();
+ const step = state.step ?? 0;
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const isAddingGame = queryClient.isMutating(addManualGameMutation) > 0;
+
+ const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'add-game-page', preferredChildFocusKey: 'steps' });
+
+ const handleReturnStep = (e: Event) =>
+ {
+ if (step <= 0)
+ {
+ HandleGoBack(router, e);
+ } else
+ {
+ const newStep = step - 1;
+ navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true });
+ }
+ };
+
+ const handleStepNavigation = (newStep: number) =>
+ {
+ if (step === newStep) return;
+ const { canNavigate } = getStepDetails(newStep, state);
+ if (!canNavigate) return;
+ navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true });
+ oneShot('openGeneric');
+ };
+
+ useShortcuts(focusKey, () => [
+ { button: GamePadButtonCode.B, label: step === 0 ? "Cancel" : "Prev Step", action: handleReturnStep },
+ { button: GamePadButtonCode.Y, label: "Cancel", action: e => HandleGoBack(router, e) },
+ {
+ button: GamePadButtonCode.L1, label: "Prev Step", action (e)
+ {
+ handleStepNavigation(Math.max(step - 1, 0));
+ },
+ },
+ {
+ button: GamePadButtonCode.R1, label: "Next Step", action (e)
+ {
+ handleStepNavigation(Math.min(step + 1, 3));
+ },
+ }
+ ], [step]);
+
+ return
+
+
+
+
+
+
+
+
+
+ {isAddingGame &&
+
+ }
+
+
+
;
+}
diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx
new file mode 100644
index 0000000..0a6ef83
--- /dev/null
+++ b/src/mainview/routes/game/update.$source.$id.tsx
@@ -0,0 +1,61 @@
+import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
+import { AutoFocus } from '@/mainview/components/AutoFocus';
+import GameLookupElement from '@/mainview/components/game/GameLookup';
+import { HeaderUI } from '@/mainview/components/Header';
+import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
+import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm';
+import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
+import { HandleGoBack } from '@/mainview/scripts/utils';
+import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { createFileRoute, useRouter } from '@tanstack/react-router';
+import { useEffect, useState } from 'react';
+import toast from 'react-hot-toast';
+
+export const Route = createFileRoute('/game/update/$source/$id')({
+ component: RouteComponent,
+});
+
+function RouteComponent ()
+{
+ const { source, id } = Route.useParams();
+ const [search, setSearch] = useState(undefined);
+
+ const router = useRouter();
+ const { data: game } = useQuery(gameQuery(source, id));
+ const update = useMutation({
+ ...customUpdateMutation,
+ async onSuccess (data, variables, onMutateResult, context)
+ {
+ toast.success("Updated Metadata");
+ await context.client.invalidateQueries(gameInvalidationQuery(source, id));
+ router.history.back();
+ },
+ });
+
+ const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `custom-update-page`, preferredChildFocusKey: 'search-field-section' });
+
+ useShortcuts(focusKey, () => [{ button: GamePadButtonCode.B, label: "Return", action (e) { HandleGoBack(router, e); }, }]);
+ useEffect(() =>
+ {
+ if (search) return;
+ setSearch(game?.name ?? undefined);
+ }, [game]);
+
+ return
+
+
+
+
+
+ update.mutate({ source, id, destination: l.source, destinationId: l.id })}
+ />
+
+
+
+
+ ;
+}
diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx
index 3742e83..bd0fc8e 100644
--- a/src/mainview/routes/games.tsx
+++ b/src/mainview/routes/games.tsx
@@ -1,12 +1,13 @@
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { CollectionsDetail } from '../components/CollectionsDetail';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
-import { GameListFilterType } from '@/shared/constants';
+import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { useSessionStorage } from 'usehooks-ts';
import HeaderSearchField from '../components/HeaderSearchField';
-import { useEffect, useState } from 'react';
-import { setFocus } from '@noriginmedia/norigin-spatial-navigation';
+import { useEffect } from 'react';
+import { RoundButton } from '../components/RoundButton';
+import { Plus } from 'lucide-react';
export const Route = createFileRoute('/games')({
component: RouteComponent,
@@ -21,6 +22,7 @@ function RouteComponent ()
const { focus } = Route.useSearch();
const { search } = Route.useSearch();
const [filter, setFilter] = useSessionStorage('all-games-filters', {});
+ const navigate = useNavigate();
useEffect(() =>
{
@@ -28,7 +30,13 @@ function RouteComponent ()
}, [search]);
return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />}
+ headerButtonElements={
+ [
+ {
+ navigate({ to: '/game/add' });
+ }} >,
+ setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />]
+ }
localFilter={filter}
setLocalFilter={setFilter}
focus={focus}
diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx
index 527fea9..8ae562a 100644
--- a/src/mainview/routes/index.tsx
+++ b/src/mainview/routes/index.tsx
@@ -3,23 +3,18 @@ import
{
Gamepad2,
Settings,
- MessageSquare,
- Image,
Search,
Power,
OctagonAlert,
Maximize,
Store,
LayoutGrid,
- PlusCircle,
- Plus,
LucideIcon,
} from "lucide-react";
import
{
createFileRoute,
- PathParamOptions,
- ToPathOption,
+ useNavigate,
useRouter,
} from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -41,12 +36,12 @@ import SaveScroll from "../components/SaveScroll";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
import { twMerge } from "tailwind-merge";
import { PlatformsList } from "../components/PlatformsList";
-import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
+import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import z from "zod";
import CollectionList from "../components/CollectionList";
import { zodValidator } from '@tanstack/zod-adapter';
-import { mobileCheck, scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from "../scripts/utils";
-import { AnimatedBackgroundContext } from "../scripts/contexts";
+import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils";
+import { AnimatedBackgroundContext, GlobalDialogContext } from "../scripts/contexts";
import Carousel from "../components/Carousel";
import { closeMutation } from "@queries/system";
import { gameQuery } from "../scripts/queries/romm";
@@ -56,6 +51,11 @@ import SelectMenu from "../components/SelectMenu";
import HeaderSearchField from "../components/HeaderSearchField";
import CardElement from "../components/CardElement";
import { Router } from "..";
+import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
+import { playGame, usePlayMutation } from "../components/game/MainActions";
+import { rommApi } from "../scripts/clientApi";
+import { ContextList, DialogEntry } from "../components/ContextDialog";
+import { FOCUS_KEYS } from "../scripts/types";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@@ -157,6 +157,9 @@ function HomeList (data: {
focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list`
});
+ const navigate = useNavigate();
+ const playGameMut = usePlayMutation(navigate);
+ const globalDialog = useContext(GlobalDialogContext);
const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) =>
{
@@ -174,6 +177,52 @@ function HomeList (data: {
router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
};
+ async function handleGamePlay (id: FrontEndId, source: string | null, sourceId: string | null)
+ {
+ const finalSource = source ?? id.source;
+ const finalId = String(sourceId ?? id.id);
+
+ const validCommands = await rommApi.api.romm.game({ source: finalSource })({ id: finalId }).commands.get();
+ if (validCommands.data)
+ {
+ const preferredCommand = localStorage.getItem(`${finalSource}-${finalId}-preferred-command`);
+ if (preferredCommand)
+ {
+ playGame(finalSource, finalId, validCommands.data.commands[JSON.parse(preferredCommand)], navigate, playGameMut.mutate);
+ } else
+ {
+ if (validCommands.data.commands.length > 1)
+ {
+ globalDialog.openContext({
+ content:
+ {
+ const option: DialogEntry = {
+ id: String(c.id),
+ content: c.label ?? String(c.id),
+ type: "primary",
+ action (ctx)
+ {
+ localStorage.setItem(`${finalSource}-${finalId}-preferred-command`, JSON.stringify(i));
+ ctx.close();
+ playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate);
+ },
+ };
+
+ return option;
+ })
+ } />
+ }, FOCUS_KEYS.GAME_LIST_CARD('games-list', id));
+ } else if (validCommands.data.commands.length === 1)
+ {
+ playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate);
+ }
+
+ }
+ }
+
+ }
+
let activeList: JSX.Element;
switch (data.selectedFilter)
{
@@ -195,6 +244,7 @@ function HomeList (data: {
activeList = <>
{
@@ -208,7 +258,7 @@ function HomeList (data: {
setBackground={bg.setBackground}
filters={{ limit: 12, orderBy: 'activity' }}
finalElement={[
- ,
+ ,
]}
emptyElement={[
diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx
index e66de07..78df354 100644
--- a/src/mainview/routes/launcher.$source.$id.tsx
+++ b/src/mainview/routes/launcher.$source.$id.tsx
@@ -1,12 +1,11 @@
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router';
import DotsLoading from '../components/backgrounds/dots';
-import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
+import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
-import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
+import { FloatingShortcuts } from '../components/Shortcuts';
import { useJobStatus } from '../scripts/utils';
-import { useEffect, useRef } from 'react';
-import { rommApi } from '../scripts/clientApi';
+import { useRef } from 'react';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx
index e9feb92..bc35faf 100644
--- a/src/mainview/routes/platform.$source.$id.tsx
+++ b/src/mainview/routes/platform.$source.$id.tsx
@@ -1,14 +1,17 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
+import { RPC_URL } from "../../shared/constants";
+import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm";
import { zodValidator } from "@tanstack/zod-adapter";
import z from "zod";
import { useLocalStorage } from "usehooks-ts";
import { RefreshCcw, Settings2 } from "lucide-react";
-import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog";
+import { ContextList, DialogEntry } from "../components/ContextDialog";
import toast from "react-hot-toast";
+import { useContext } from "react";
+import { GlobalDialogContext } from "../scripts/contexts";
export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent,
@@ -22,7 +25,7 @@ function PlatformTitle (data: {})
const { source, id } = Route.useParams();
const { data: platform } = useQuery(platformQuery(source, id));
- return
+ return
{!!platform &&
}${platform.path_cover}`})
}
@@ -36,13 +39,15 @@ function RouteComponent ()
const { source, id } = Route.useParams();
const router = useRouter();
const { countHint } = Route.useSearch();
+ const { data: platform } = useQuery(platformQuery(source, id));
const [filter, setFilter] = useLocalStorage
("platforms-filters", {});
const updatePlatform = useMutation({
- ...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context)
+ ...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context)
{
context.client.invalidateQueries(localPlatformFilter(id));
},
});
+ const globalDialog = useContext(GlobalDialogContext);
const deletePlatform = useMutation({
...deletePlatformMutation(id),
onError (error, variables, onMutateResult, context)
@@ -56,7 +61,7 @@ function RouteComponent ()
},
});
const settingsOptions: DialogEntry[] = [];
- if (source === 'local')
+ if (source === 'local' || platform?.hasLocal)
{
settingsOptions.push({
id: 'update-platform',
@@ -70,9 +75,12 @@ function RouteComponent ()
router.navigate({ replace: true });
},
});
+ }
+ if (source === 'local')
+ {
settingsOptions.push({
- id: 'update-platform',
+ id: 'delete-platform',
type: "error",
content: "Delete",
icon: deletePlatform.isPending ? : ,
@@ -83,10 +91,6 @@ function RouteComponent ()
});
}
- const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', {
- content:
- });
-
return (
,
action ()
{
- setPlatformSettingsOpen(true);
+ globalDialog.openContext({ content:
}, 'open-platform-settings-btn');
},
}]}
countHint={countHint}
title={
}
filters={{ platform_id: Number(id), platform_source: source }}
/>
- {platformSettingsDialog}
);
}
diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx
index b6db34f..da450d4 100644
--- a/src/mainview/routes/settings/about.tsx
+++ b/src/mainview/routes/settings/about.tsx
@@ -1,11 +1,9 @@
-import { Button } from '@/mainview/components/options/Button';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
-import { checkUpdateMutation, hasUpdateQuery, systemInfoQuery, updateMutation } from '@queries/system';
-import { useMutation, useQuery } from '@tanstack/react-query';
+import { systemInfoQuery } from '@queries/system';
+import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
-import { ArrowUpCircle, CircleFadingArrowUp, RefreshCcw } from 'lucide-react';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx
index 0e63a80..eacd432 100644
--- a/src/mainview/routes/settings/accounts.tsx
+++ b/src/mainview/routes/settings/accounts.tsx
@@ -7,13 +7,14 @@ import
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import classNames from "classnames";
-import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react";
+import { Info, Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react";
import
{
useEffect,
useRef,
} from "react";
-import { RommLoginDataSchema, RPC_URL } from "@shared/constants";
+import { RPC_URL } from "@shared/constants";
+import { RommLoginDataSchema } from '@simeonradivoev/gameflow-sdk/shared';
import toast from "react-hot-toast";
import { OptionSpace } from "../../components/options/OptionSpace";
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
@@ -26,9 +27,13 @@ import { TwitchIcon } from "@/mainview/scripts/brandIcons";
import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings";
import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery, invalidateLogin } from "@queries/romm";
import { systemApi } from "@/mainview/scripts/clientApi";
+import z from "zod";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
+ validateSearch: z.object({
+ focus: z.string().optional()
+ }),
});
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; })
@@ -221,6 +226,8 @@ function RouteComponent ()
} type="text" />} />
} type="password" placeholder="Password" />} />
+
+ For Romm Client API Token open plugin settings
} />
diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx
index 5a32eec..5adee26 100644
--- a/src/mainview/routes/settings/directories.tsx
+++ b/src/mainview/routes/settings/directories.tsx
@@ -13,9 +13,13 @@ import { systemApi } from '@/mainview/scripts/clientApi';
import useActiveControl from '@/mainview/scripts/gamepads';
import { changeDownloadsMutation } from '@queries/settings';
import { downloadDrivesQuery } from '@/mainview/scripts/queries/system';
+import { DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared';
+import { zodValidator } from '@tanstack/zod-adapter';
+import z from 'zod';
export const Route = createFileRoute('/settings/directories')({
component: RouteComponent,
+ validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
});
function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; })
diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx
index 363ef45..17344df 100644
--- a/src/mainview/routes/settings/emulators.tsx
+++ b/src/mainview/routes/settings/emulators.tsx
@@ -4,11 +4,12 @@ import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '../../components/options/Button';
-import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
+import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
-import { RPC_URL, SettingsSchema } from '../../../shared/constants';
+import { RPC_URL } from '../../../shared/constants';
+import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
import emulators from '@emulators';
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
@@ -20,10 +21,15 @@ import { FOCUS_KEYS } from '@/mainview/scripts/types';
import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils';
import { SettingsOption } from '@/mainview/components/options/SettingsOption';
import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown';
+import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared';
+import { zodValidator } from '@tanstack/zod-adapter';
+import z from 'zod';
+import { isUrl } from '@/shared/utils';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
pendingComponent: EmulatorsPending,
+ validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
});
function EmulatorsPending ()
@@ -80,7 +86,10 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
};
- return
+ return
+ Custom Emulator Path
+ Manually Pick a path to an emulator if not automatically found.
+ }>
@@ -55,6 +60,7 @@ function Plugin (data: {
>
{data.plugin.hasSettings ? : }
+ {data.plugin.canUninstall && {uninstall.isPending ? : }}
{data.plugin.canDisable && data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
;
diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx
index 5df28ac..625e884 100644
--- a/src/mainview/routes/settings/route.tsx
+++ b/src/mainview/routes/settings/route.tsx
@@ -7,7 +7,6 @@ import
{
Outlet,
createFileRoute,
- useMatch,
useMatchRoute,
useRouter,
useRouterState,
@@ -17,6 +16,7 @@ import classNames from "classnames";
import
{
ArrowBigLeft,
+ Cog,
FingerprintPattern,
HardDrive,
Info,
@@ -27,10 +27,8 @@ import
} from "lucide-react";
import { JSX, useMemo } from "react";
import { twMerge } from "tailwind-merge";
-import z from "zod";
-import { SettingsSchema } from "../../../shared/constants";
-import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
-import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
+import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
+import Shortcuts from "@/mainview/components/Shortcuts";
import { HandleGoBack } from "@/mainview/scripts/utils";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import { oneShot } from "@/mainview/scripts/audio/audio";
@@ -38,9 +36,6 @@ import SelectMenu from "@/mainview/components/SelectMenu";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
- validateSearch: z.object({
- focus: z.keyof(SettingsSchema).optional()
- }),
staticData: {
enterSound: 'openSettings'
}
@@ -161,6 +156,12 @@ function SettingsMenu (data: {})
label="Plugins"
icon={
}
/>
+
}
+ />
-
+
{
- const url = new URL(`${RPC_URL(__HOST__)}${c}`);
+ const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
url.searchParams.delete('ts');
return url;
});
diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx
index 178eea0..dcb53c8 100644
--- a/src/mainview/routes/store/tab/index.tsx
+++ b/src/mainview/routes/store/tab/index.tsx
@@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query';
import { autoEmulatorsQuery } from '@queries/settings';
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks';
+import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent
@@ -126,7 +127,7 @@ export function RouteComponent ()
{}
{!!crucialEmulators && crucialEmulators?.length > 0 && storeContext.showDetails('emulator', 'store', id, focus)}
+ onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)}
emulators={crucialEmulators} />}
+ {
+ navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } });
+ };
+ const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction });
+ const handleRefresh = (client: QueryClient) =>
+ {
+ client.invalidateQueries(allPluginsFilter);
+ navigate({ to: '/store/tab/plugins', replace: true });
+ };
+ const update = useMutation({
+ ...updatePluginMutation(data.plugin.package.name),
+ onSuccess (data, variables, onMutateResult, context)
+ {
+ handleRefresh(context.client);
+ },
+ });
+ const install = useMutation({
+ ...installPluginMutation(data.plugin.package.name),
+ onSuccess (f, variables, onMutateResult, context)
+ {
+ handleRefresh(context.client);
+ }
+ });
+ const uninstall = useMutation({
+ ...uninstallPluginMutation(data.plugin.package.name),
+ onSuccess (f, variables, onMutateResult, context)
+ {
+ handleRefresh(context.client);
+ }
+ });
+ useShortcuts(focusKey, () =>
+ {
+ const shortcuts: Shortcut[] = [{
+ label: "Details", button: GamePadButtonCode.A, action (e)
+ {
+ onAction();
+ },
+ }];
+
+ if (data.plugin.installed)
+ {
+ shortcuts.push({
+ label: "Uninstall",
+ button: GamePadButtonCode.X,
+ action (e)
+ {
+ uninstall.mutate();
+ },
+ });
+
+ if (data.plugin.update)
+ {
+ shortcuts.push({
+ label: "Update",
+ button: GamePadButtonCode.Y,
+ action (e)
+ {
+ update.mutate();
+ },
+ });
+ }
+
+ } else
+ {
+ shortcuts.push({
+ label: "Install",
+ button: GamePadButtonCode.X,
+ action (e)
+ {
+ install.mutate();
+ },
+ });
+ }
+ return shortcuts;
+ }, [data.plugin.installed, install.isPending, uninstall.isPending]);
+ return
+
+
+ {data.plugin.installed && }
+ {data.plugin.update && }
+ {data.plugin.package.name}
+ {(install.isPending || uninstall.isPending) && }
+
+
{data.plugin.package.description}
+
{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) => - {k}
)}
+
+ - {data.plugin.package.publisher.username}
+
+ - {data.plugin.package.version}
+
+ - {prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}
+
+ - {data.plugin.package.license}
+ {install.isPending && <>
+
+ - installing
+ >}
+ {uninstall.isPending && <>
+
+ - uninstalling
+ >}
+
+
+
+
+
+ {data.plugin.downloads.monthly}
+
+
+
;
+}
+
+function RouteComponent ()
+{
+ const [search] = useSessionStorage(`${Route.to}-search`, undefined);
+ const { data: plugins } = useQuery(pluginsQuery(search));
+ const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" });
+ return
+
+
+
+ {plugins?.objects.map((p, i) =>
)}
+
+
+
;
+}
diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx
index d771fa6..81a5a01 100644
--- a/src/mainview/routes/store/tab/route.tsx
+++ b/src/mainview/routes/store/tab/route.tsx
@@ -3,18 +3,19 @@ import { FilterUI } from '@/mainview/components/Filters';
import { HeaderUI } from '@/mainview/components/Header';
import HeaderSearchField from '@/mainview/components/HeaderSearchField';
import SelectMenu from '@/mainview/components/SelectMenu';
-import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts';
+import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts';
import { gameQuery } from '@/mainview/scripts/queries/romm';
import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store';
-import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
+import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useQueryClient } from '@tanstack/react-query';
import { useMatchRoute, useRouter } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
-import { useRef, useState } from 'react';
+import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react';
+import { useRef } from 'react';
import { useSessionStorage } from 'usehooks-ts';
import z from 'zod';
@@ -93,9 +94,11 @@ function RouteComponent ()
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const filters: Record = {
- home: { label: "Home", selected: useIsSettings(''), },
- emulators: { label: "Emulators", selected: useIsSettings('emulators') },
- games: { label: "Games", selected: useIsSettings('games') }
+ home: { label: "Home", icon: , selected: useIsSettings(''), },
+ emulators: { label: "Emulators", icon: , selected: useIsSettings('emulators') },
+ games: { label: "Games", icon: , selected: useIsSettings('games') },
+ download: { label: "Download", icon: , selected: useIsSettings('download') },
+ plugins: { label: "Plugins", icon: , selected: useIsSettings('plugins') }
};
const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined);
const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined);
@@ -104,7 +107,7 @@ function RouteComponent ()
{
if (type === 'emulator')
{
- if (source === 'local') return;
+ if (!source || source === 'local') return;
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
}
else if (type === 'game')
diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts
index 743b4ea..430e4de 100644
--- a/src/mainview/scripts/audio/audio.ts
+++ b/src/mainview/scripts/audio/audio.ts
@@ -3,7 +3,7 @@ import sounds from '../../assets/sounds.ogg';
import soundSprites from '../../assets/sounds.json';
import { getLocalSetting } from '../utils';
import { hapticMap } from '../gamepads';
-import { soundMap } from './audioConstants';
+import { soundMap, SoundMapEntry } from './audioConstants';
const timingMap = new Map();
@@ -37,25 +37,23 @@ function sinRandom ()
return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI);
}
-function cosRandom ()
-{
- return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI);
-}
function random ()
{
return Math.random() * 2 - 1;
}
-export function oneShot (id: keyof typeof soundMap)
+export function oneShot (id: keyof typeof soundMap, options?: { volume?: number; })
{
const currentDate = timingMap.get(id);
if (!getLocalSetting('soundEffects')) return;
- if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return;
- const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; };
+ const soundValue = soundMap[id] as SoundMapEntry;
+ const maxDelay = soundValue.maxDelay ?? 100;
+ if (currentDate && new Date().getTime() - currentDate.getTime() <= maxDelay) return;
+
const instanceId = sound.play(soundValue.key);
const baseVolume = getLocalSetting("soundEffectsVolume") / 100;
- sound.volume(Math.min(baseVolume * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId);
+ sound.volume(Math.min(baseVolume * (soundValue.volume ?? 1) * (options?.volume ?? 1) * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId);
sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId);
timingMap.set(id, new Date());
}
diff --git a/src/mainview/scripts/audio/audioConstants.ts b/src/mainview/scripts/audio/audioConstants.ts
index a877e12..25c85c6 100644
--- a/src/mainview/scripts/audio/audioConstants.ts
+++ b/src/mainview/scripts/audio/audioConstants.ts
@@ -3,6 +3,15 @@ import soundSprites from '../../assets/sounds.json';
const volumeVariation = 0.05;
const rateVariation = 0.02;
+export interface SoundMapEntry
+{
+ key: keyof typeof soundSprites.sprite;
+ rateVariation?: number;
+ volumeVariation?: number;
+ volume?: number;
+ maxDelay?: number;
+}
+
export const soundMap = {
openDetails: { key: 'Classic UI SFX - Chords #2' },
returnGeneric: { key: 'Classic UI SFX - Short - Low #2' },
@@ -14,10 +23,16 @@ export const soundMap = {
selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation },
closeContext: { key: 'Classic UI SFX - Short - High #19' },
openContext: { key: 'Classic UI SFX - Short - High #22' },
+ openKeyboard: { key: 'Classic UI SFX - Short - High #25' },
openStore: { key: 'Classic UI SFX - Chords #16' },
openSettings: { key: 'Classic UI SFX - Short - High #8' },
click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation },
clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation },
+ keyPress: { key: "UI_Single_Set 5_02", rateVariation, volumeVariation },
+ keyPressReturn: { key: "UI_Single_Set 5_04", rateVariation, volumeVariation },
+ keyPressSpace: { key: "UI_Single_Set 5_03", rateVariation, volumeVariation },
+ keyPressBackspace: { key: "UI_Single_Set 5_01", rateVariation, volumeVariation },
+ keyHover: { key: "UI_Single_Set 11_02", rateVariation, volumeVariation, volume: 0.5, maxDelay: 60 },
invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation },
launch: { key: "UI SFX_InGameMenu_Open" }
-} satisfies Record;
\ No newline at end of file
+} satisfies Record;
\ No newline at end of file
diff --git a/src/mainview/scripts/brandIcons.tsx b/src/mainview/scripts/brandIcons.tsx
index ef35534..bf2e576 100644
--- a/src/mainview/scripts/brandIcons.tsx
+++ b/src/mainview/scripts/brandIcons.tsx
@@ -3,4 +3,8 @@ export const TwitchIcon =
;
+export const IGDBIcon = ;
+
+export const Rclone = ;
+
export const FlatpackIcon = ;
\ No newline at end of file
diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts
index d987199..0c958b1 100644
--- a/src/mainview/scripts/contexts.ts
+++ b/src/mainview/scripts/contexts.ts
@@ -1,4 +1,4 @@
-import { SystemInfoType } from "@/shared/constants";
+import { SystemInfoType, Drive, AppInfoContext } from '@simeonradivoev/gameflow-sdk/shared';
import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
import { createContext } from "react";
import { Shortcut } from "./shortcuts";
@@ -45,6 +45,16 @@ export const ShortcutsContext = createContext({} as {
export const SystemInfoContext = createContext({} as SystemInfoType | undefined);
+export const AppContext = createContext({} as AppInfoContext);
+
+export const GlobalDialogContext = createContext({} as {
+ openContext: (options: {
+ content: any;
+ preferredChildFocusKey?: string;
+ onClose?: () => void;
+ }, focusKey: string) => void;
+});
+
export const GameDetailsContext = createContext<{
update: () => void;
}>({} as any);
\ No newline at end of file
diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts
index 251f000..5959d90 100644
--- a/src/mainview/scripts/gamepads.ts
+++ b/src/mainview/scripts/gamepads.ts
@@ -1,7 +1,7 @@
import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react";
-import { getLocalSetting, mobileCheck } from "./utils";
+import { getLocalSetting, isTextInputFocused, mobileCheck } from "./utils";
import { oneShot } from "./audio/audio";
import { Router } from "@/mainview";
@@ -98,6 +98,11 @@ const throttleMap = new Map();
const throttleAcceleration = new Map();
function throttleNav (key: string, dir: string, event: Event)
{
+ if (isTextInputFocused())
+ {
+ return false;
+ }
+
const minSpeed = 150;
const maxSpeed = 300;
const currentDate = new Date();
diff --git a/src/mainview/scripts/queries/plugins.ts b/src/mainview/scripts/queries/plugins.ts
index 323b168..8e6e948 100644
--- a/src/mainview/scripts/queries/plugins.ts
+++ b/src/mainview/scripts/queries/plugins.ts
@@ -1,4 +1,4 @@
-import { mutationOptions, queryOptions } from "@tanstack/react-query";
+import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query";
import { pluginsApi } from "../clientApi";
export const getAllPluginsQuery = queryOptions({
@@ -14,7 +14,7 @@ export const getAllPluginsQuery = queryOptions({
export const getPluginDetailsQuery = (source: string) => queryOptions({
queryKey: ['plugins', source], queryFn: async () =>
{
- const { data, error } = await pluginsApi.plugins({ id: source }).get();
+ const { data, error } = await pluginsApi.plugins({ id: encodeURIComponent(source) }).get();
if (error) throw error;
return data;
}
@@ -24,7 +24,51 @@ export const enablePluginMutation = mutationOptions({
mutationKey: ['plugin', 'enable'],
mutationFn: async (vars: { id: string, enabled: boolean; }) =>
{
- const { error } = await pluginsApi.plugins({ id: vars.id }).post({ enabled: vars.enabled });
+ const { error } = await pluginsApi.plugins({ id: encodeURIComponent(vars.id) }).post({ enabled: vars.enabled });
if (error) throw error;
}
+});
+
+export const installPluginMutation = (id: string) => mutationOptions({
+ mutationKey: ['plugin', 'install', id],
+ mutationFn: async () =>
+ {
+ const { data, error } = await pluginsApi.plugins.install.post({ id });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const updatePluginMutation = (id: string) => mutationOptions({
+ mutationKey: ['plugin', 'update', id],
+ mutationFn: async () =>
+ {
+ const { data, error } = await pluginsApi.plugins.update.post({ id });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const uninstallPluginMutation = (id: string) => mutationOptions({
+ mutationKey: ['plugin', 'uninstall', id],
+ mutationFn: async () =>
+ {
+ const { data, error } = await pluginsApi.plugins.uninstall.post({ id: id });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const pluginFilter = (id: string): QueryFilters => ({
+ predicate (query)
+ {
+ return query.queryKey.includes(id);
+ },
+});
+
+export const allPluginsFilter: QueryFilters = ({
+ predicate (query)
+ {
+ return query.queryKey.includes('plugin') || query.queryKey.includes('plugins');
+ },
});
\ No newline at end of file
diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts
index 7ebf4db..cf059f3 100644
--- a/src/mainview/scripts/queries/romm.ts
+++ b/src/mainview/scripts/queries/romm.ts
@@ -1,6 +1,7 @@
-import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
+import { DefaultRommStaleTime } from "@/shared/constants";
+import { GameListFilterType, RommLoginDataSchema, FrontEndId, DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared';
import { rommApi, settingsApi } from "../clientApi";
-import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
+import { infiniteQueryOptions, InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query";
import z from "zod";
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
@@ -166,6 +167,9 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
return data;
}
});
+export const allGamesInvalidateQuery: QueryFilters = {
+ queryKey: ['games']
+};
export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({
predicate (query)
{
@@ -177,7 +181,7 @@ export const gameInvalidationQuery = (source: string, id: string): QueryFilters
export const validateSourceQuery = (source: string, id: string) => queryOptions({
queryKey: ["game", source, id, "validate"], queryFn: async () =>
{
- const { data, error } = await rommApi.api.romm.game({ source })({ id }).validate.get();
+ const { data } = await rommApi.api.romm.game({ source })({ id }).validate.get();
return data;
}
});
@@ -192,16 +196,19 @@ export const fixSourceMutation = mutationOptions({
export const updateSourceMutation = mutationOptions({
mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) =>
{
- const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post();
+ const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post({
+ source: source,
+ id: id
+ });
if (error) throw error;
return data;
}
});
-export const updatePlatformMutation = (id: string) => mutationOptions({
- mutationKey: ['platform', 'local', 'update', id],
+export const updatePlatformMutation = (source: string, id: string) => mutationOptions({
+ mutationKey: ['platform', source, 'update', id],
mutationFn: async () =>
{
- const { data, error } = await rommApi.api.romm.platform.local({ id }).update.post();
+ const { data, error } = await rommApi.api.romm.platform({ source })({ id }).update.post();
if (error) throw error;
return data;
}
@@ -229,4 +236,98 @@ export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions(
if (error) throw error;
return data;
}
+});
+
+export const gameLookupQuery = (search: string | undefined) => queryOptions({
+ queryKey: ['game', 'lookup', search],
+ queryFn: async () =>
+ {
+ if (!search) return [];
+ const { data, error } = await rommApi.api.romm.lookup.get({ query: { search } });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const gameLookupDetails = (source: string | undefined, id: string | undefined) => queryOptions({
+ enabled: !!source && !!id,
+ queryKey: ['game', 'lookup', source, id],
+ queryFn: async () =>
+ {
+ const { data, error } = await rommApi.api.romm.lookup({ source: source! })({ id: id! }).get();
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const platformLookupMatchQuery = (source: string | undefined, id: number | undefined) => queryOptions({
+ enabled: !!source && !!id,
+ queryKey: ['platform', 'lookup', 'match', source, id],
+ queryFn: async () =>
+ {
+ const { data, error } = await rommApi.api.romm.platform.lookup.match({ source: source! })({ id: id! }).get();
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const customUpdateMutation = mutationOptions({
+ mutationKey: ['game', 'custom-update'], mutationFn: async (args: { source: string, id: string, destination: string, destinationId: string; }) =>
+ {
+ const { data, error } = await rommApi.api.romm.game({ source: args.source })({ id: args.id }).update.post({ source: args.destination, id: args.destinationId });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const addManualGameMutation = mutationOptions({
+ mutationKey: ['game', 'custom-add'],
+ mutationFn: async (args: { source: string, id: string, gamePath: string, platformId: number; }) =>
+ {
+ const { data, error } = await rommApi.api.romm.add.custom.post({
+ source: args.source,
+ id: args.id,
+ gamePath: args.gamePath,
+ platformId: args.platformId
+ });
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const downloadsLookupQuery = (filter: DownloadsLookupFilter) => infiniteQueryOptions<{
+ data: DownloadLookupEntry[],
+ totalCount: number,
+ nextPage: number;
+ hadMatchers: boolean;
+}>({
+ initialPageParam: 1,
+ queryKey: ["downloads", filter],
+ getNextPageParam: (lastPage, pages) => lastPage.nextPage,
+ queryFn: async (params) =>
+ {
+ const pageParam = params.pageParam as number;
+ const { data, error } = await rommApi.api.romm.downloads.lookup.get({ query: { ...filter, page: pageParam } });
+ if (error) throw error;
+ return { data: data.matches, totalCount: data.totalCount, hadMatchers: data.hadMatchers, nextPage: pageParam + 1 };
+ }
+});
+
+export const downloadLookupQuery = (source: string, id: string) => queryOptions({
+ queryKey: ["downloads", source, id],
+ queryFn: async () =>
+ {
+ const { data, error } = await rommApi.api.romm.download.lookup({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get();
+ if (error) throw error;
+ return data;
+ }
+});
+
+export const downloadLookupFiltersQuery = queryOptions({
+ queryKey: ['game', 'filters'], queryFn: async () =>
+ {
+ const { data, error } = await rommApi.api.romm.download.lookup.filters.get();
+ if (error) throw error;
+ return data;
+ }
});
\ No newline at end of file
diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts
index e0f605e..03956af 100644
--- a/src/mainview/scripts/queries/settings.ts
+++ b/src/mainview/scripts/queries/settings.ts
@@ -138,7 +138,7 @@ export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions
queryKey: ['settings', source, 'definitions'],
queryFn: async () =>
{
- const { data: value, error } = await settingsApi.api.settings.definitions({ source }).get();
+ const { data: value, error } = await settingsApi.api.settings.definitions({ source: encodeURIComponent(source) }).get();
if (error) throw error;
return value;
@@ -148,7 +148,7 @@ export const getPluginSettingQuery = (source: string, id: string) => queryOption
queryKey: ["setting", source, id],
queryFn: async () =>
{
- const { data, error } = await settingsApi.api.settings({ source })({ id }).get();
+ const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get();
if (error) throw error;
return data;
@@ -158,7 +158,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation
mutationKey: ["setting", source, id],
mutationFn: async (value: any) =>
{
- const { data, error } = await settingsApi.api.settings({ source })({ id }).put({ value });
+ const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).put({ value });
if (error) throw error;
return data;
@@ -167,7 +167,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation
export const getPluginActionsQuery = (source: string) => queryOptions({
queryKey: ['plugin', source, 'actions'], queryFn: async () =>
{
- const { data, error } = await settingsApi.api.settings.actions({ source }).get();
+ const { data, error } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) }).get();
if (error) throw error;
return data;
@@ -177,7 +177,7 @@ export const pluginActionMutation = (source: string, id: string) => mutationOpti
mutationKey: ["plugin", source, "action"],
mutationFn: async () =>
{
- const { data, error, response } = await settingsApi.api.settings.actions({ source })({ id }).post();
+ const { data, error, response } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).post();
if (error) throw error;
return { data: data as any, response };
diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts
index 9e506a9..a428f40 100644
--- a/src/mainview/scripts/queries/store.ts
+++ b/src/mainview/scripts/queries/store.ts
@@ -1,7 +1,6 @@
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
import { rommApi, storeApi } from "../clientApi";
-import { GameListFilterType } from "@/shared/constants";
-
+import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared';
export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({
queryKey: ['store-emulators', filters], queryFn: async () =>
@@ -96,4 +95,22 @@ export const getUpdateInfoForEmulator = (id: string) => queryOptions({
if (error) throw error;
return data;
}
+});
+export const pluginsQuery = (search?: string) => queryOptions({
+ queryKey: ['plugins', 'store', search ?? 'all'],
+ queryFn: async () =>
+ {
+ const { data, error } = await storeApi.api.store.plugins.get({ query: { search } });
+ if (error) throw error;
+ return data;
+ }
+});
+export const pluginDetailsQuery = (id: string) => queryOptions({
+ queryKey: ['plugin', 'store', id],
+ queryFn: async () =>
+ {
+ const { data, error } = await storeApi.api.store.plugin.get({ query: { plugin: id } });
+ if (error) throw error;
+ return data;
+ }
});
\ No newline at end of file
diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts
index 35316b7..c947d23 100644
--- a/src/mainview/scripts/shortcuts.ts
+++ b/src/mainview/scripts/shortcuts.ts
@@ -2,6 +2,7 @@ import { DependencyList, useEffect, useState } from "react";
import { GamepadButtonEvent } from "./gamepads";
import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
+import { isTextInputFocused } from "./utils";
const shortcutMap = new Map Shortcut[])[]>();
const conflictSet = new Set();
@@ -123,12 +124,21 @@ export function useShortcutContext ()
if (e.key === 'Escape')
{
shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B }));
- } else if (e.key === 'Backspace')
+ } else
{
- shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X }));
- } else if (e.key === ' ')
- {
- shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y }));
+ // We use backspace and space in typing
+ if (isTextInputFocused())
+ {
+ return false;
+ }
+
+ if (e.key === 'Backspace')
+ {
+ shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X }));
+ } else if (e.key === ' ')
+ {
+ shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y }));
+ }
}
};
diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts
index e7630dc..fbfcb36 100644
--- a/src/mainview/scripts/types.ts
+++ b/src/mainview/scripts/types.ts
@@ -1,3 +1,5 @@
+import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
+
export const FOCUS_KEYS = {
NAV_CATEGORIES: "NAV_CATEGORIES",
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
@@ -10,5 +12,9 @@ export const FOCUS_KEYS = {
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
+ GAME_LIST_CARD: (list: string, id: FrontEndId) => `LIST_${list}_GAME_${id.source}_${id.id}`,
+ GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
STATS_SECTION: "STATS_SECTION",
+ PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}`,
+ DOWNLOAD_ENTRY: (source: string, id: string) => `DOWNLOAD_${source}_${id}`
} as const;
\ No newline at end of file
diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts
index 37b2da1..6635bd6 100644
--- a/src/mainview/scripts/utils.ts
+++ b/src/mainview/scripts/utils.ts
@@ -1,5 +1,5 @@
-import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
-import { DependencyList, FocusEventHandler, RefObject, useEffect, useRef, useState } from "react";
+import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared';
+import { DependencyList, RefObject, useEffect, useRef, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { jobsApi, systemApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc";
@@ -13,6 +13,12 @@ export type ScrollSaveParams = {
storage?: "session" | "local";
shouldSave?: boolean;
};
+
+export function isTextInputFocused ()
+{
+ return document.activeElement && document.activeElement instanceof HTMLInputElement;
+}
+
export function useScrollSave (data: ScrollSaveParams)
{
useEffect(() =>
@@ -372,7 +378,7 @@ export function useOnNavigateBack (callback: (state: { sound?: keyof typeof soun
}, [router]);
}
-export function showKeyboardHandler (activeControl: string, node?: HTMLInputElement)
+export function showKeyboardHandler (activeControl: string | undefined, node?: HTMLInputElement)
{
if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch'))
{
diff --git a/src/mainview/scripts/windowEvents.ts b/src/mainview/scripts/windowEvents.ts
index c6f7ab8..88dd926 100644
--- a/src/mainview/scripts/windowEvents.ts
+++ b/src/mainview/scripts/windowEvents.ts
@@ -2,7 +2,7 @@ import { settingsApi } from "./clientApi";
const handleResize = () =>
{
- settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
+ settingsApi.api.settings.local({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
};
window.addEventListener("resize", handleResize);
import.meta.hot?.dispose(() => window.removeEventListener('resize', handleResize));
@@ -13,7 +13,7 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
{
if (lastWindowPosX != window.screenX || lastWindowPosY != window.screenY)
{
- settingsApi.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } });
+ settingsApi.api.settings.local({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } });
}
lastWindowPosX = window.screenX;
diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts
index 0ac95d6..2a0c101 100644
--- a/src/mainview/types.d.ts
+++ b/src/mainview/types.d.ts
@@ -46,6 +46,17 @@ declare interface FocusEventDetails
focusKeyChanged: boolean;
}
+declare interface GameMeta extends FocusParams
+{
+ id: string,
+ onSelect?: () => void,
+ onQuickAction?: () => void,
+ title: string,
+ subtitle?: any,
+ previewUrls?: string | URL[];
+ previewSrcset?: string;
+};
+
declare interface FocusParams
{
onFocus?: (focusKey: string, node: HTMLElement, details: Record) => void;
diff --git a/src/packages/gameflow-sdk/README.md b/src/packages/gameflow-sdk/README.md
new file mode 100644
index 0000000..8338233
--- /dev/null
+++ b/src/packages/gameflow-sdk/README.md
@@ -0,0 +1,30 @@
+# Gameflow Deck SDK
+
+This is the type definitions for Gameflow Deck plugins.
+
+## Developing a plugin
+
+The plugin must have a default export class of type `PluginType`. It exposes the context and all the hooks to be tapped.
+Gameflow uses the [Tapable Hooks](https://github.com/webpack/tapable).
+
+The package must expose a main script gameflow will import and validate. It must implement the type fields on `PluginDescriptionType`.
+
+## Publishing
+
+For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs.
+Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry)
+
+## Dependencies
+
+Peer dependencies will not be installed when the run adds the plugin package. They are provided by gameflow.
+All peer dependencies can be marked as external as gameflow provides it. There is a helper build script that does all that for you, to run it use.
+
+`bunx gameflow-build --entry=index.ts`
+
+supported arguments are
+`--entry` the entry of the app to build
+`--outdir` Where to build. Default is 'dist'
+`--minify` Minify the code. Default is 'false'
+`--sourcemap` Include a source map. Default is 'none'
+
+If you want to include dependencies that gameflow does not provide you have to bundle them in. Gameflow does not load dependencies for you.
diff --git a/src/packages/gameflow-sdk/build.ts b/src/packages/gameflow-sdk/build.ts
new file mode 100755
index 0000000..9f18b88
--- /dev/null
+++ b/src/packages/gameflow-sdk/build.ts
@@ -0,0 +1,27 @@
+#!/usr/bin/env bun
+
+import pkg from './package.json';
+
+import { parseArgs } from "util";
+
+const { values } = parseArgs({
+ args: Bun.argv.slice(2),
+ options: {
+ outdir: { type: "string", default: "dist" },
+ minify: { type: "boolean", default: false },
+ sourcemap: { type: "string", default: "none" }, // "none" | "inline" | "external"
+ entry: { type: "string", default: "src/index.ts" },
+ },
+ allowPositionals: true,
+});
+
+await Bun.build({
+ entrypoints: [values.entry],
+ outdir: values.outdir,
+ minify: values.minify,
+ sourcemap: values.sourcemap as any,
+ external: [...Object.keys(pkg.peerDependencies), pkg.name],
+ target: "bun",
+});
+
+console.log(`✅ Built to ${values.outdir}`);
\ No newline at end of file
diff --git a/src/packages/gameflow-sdk/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts
new file mode 100644
index 0000000..1b73daa
--- /dev/null
+++ b/src/packages/gameflow-sdk/hooks/app.ts
@@ -0,0 +1,49 @@
+import { AsyncSeriesBailHook } from "tapable";
+import AuthHooks from "./auth";
+import EmulatorHooks from "./emulators";
+import GameHooks from "./games";
+import StoreHooks from "./store";
+import { DownloadFileEntry, ProgressStats } from "../shared";
+
+export class GameflowHooks
+{
+ games = new GameHooks();
+ emulators = new EmulatorHooks();
+ auth = new AuthHooks();
+ store = new StoreHooks();
+ /** Download the given files and return their final paths. */
+ downloadFiles = new AsyncSeriesBailHook<[ctx: {
+ /** Unique ID of the download */
+ id: string,
+ /** The root download path. Each file has it's own download sub path */
+ downloadPath: string,
+ abortSignal?: AbortSignal,
+ /** Authentication needed for download. Should be put in the headers. */
+ auth?: string,
+ /** The files to download */
+ files: DownloadFileEntry[];
+ /** Call it to update progress in the UI */
+ updateProgress: (stats: ProgressStats) => void;
+
+ }], {
+ /** What downloaded the files. Will be passed to {@link postDownloadFiles} files hook. */
+ source: string,
+ /** The file paths ot the downloaded files. */
+ files: string[];
+ } | undefined>(['ctx']);
+ /** Called after {@link downloadFiles} has finished downloading.
+ * @returns The modified file paths.
+ */
+ postDownloadFiles = new AsyncSeriesBailHook<[ctx: {
+ /** Who downloaded the files. Passed from the {@link downloadFiles} hook. */
+ source: string;
+ /** Can be directories or files */
+ files: string[];
+ /** The root downloads folder. */
+ downloadPath: string,
+ /** The sub path where the archive should be extracted to. This will be a sub path of `path_fs` */
+ extract_path?: string;
+ /** This is the parent path for the extracted files. */
+ path_fs?: string;
+ }], string[] | undefined>(['ctx']);
+}
\ No newline at end of file
diff --git a/src/bun/api/hooks/auth.ts b/src/packages/gameflow-sdk/hooks/auth.ts
similarity index 71%
rename from src/bun/api/hooks/auth.ts
rename to src/packages/gameflow-sdk/hooks/auth.ts
index 7234992..cb8dd1b 100644
--- a/src/bun/api/hooks/auth.ts
+++ b/src/packages/gameflow-sdk/hooks/auth.ts
@@ -1,6 +1,8 @@
-import { AsyncSeriesHook } from "tapable";
-export class AuthHooks
+import { AsyncSeriesHook } from "tapable";
+import { DownloadFileEntry } from "../shared";
+
+export default class AuthHooks
{
loginComplete = new AsyncSeriesHook<[ctx: {
service: string;
diff --git a/src/bun/api/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts
similarity index 67%
rename from src/bun/api/hooks/emulators.ts
rename to src/packages/gameflow-sdk/hooks/emulators.ts
index 4ac51e7..d852d06 100644
--- a/src/bun/api/hooks/emulators.ts
+++ b/src/packages/gameflow-sdk/hooks/emulators.ts
@@ -1,17 +1,11 @@
-import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants";
+
+import { EmulatorPostInstallContextType } from "../index";
+import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
-interface EmulatorPostInstallContext
-{
- emulator: string;
- emulatorPackage?: EmulatorPackageType;
- path: string;
- update: boolean;
- info: EmulatorDownloadInfoType;
-}
-
-export class EmulatorHooks
+export default class EmulatorHooks
{
+ /** Download emulator bios files */
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
emulator: string;
systems: EmulatorSystem[];
@@ -21,8 +15,10 @@ export class EmulatorHooks
/**
* Triggered when emulator is downloaded or updated
*/
- emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']);
+ emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']);
+ /** Find locations of emulators on the system. Be it already installed ones or ones downloaded by the store. */
findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
+ /** Match emulators for a given system */
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
constructor()
@@ -32,7 +28,7 @@ export class EmulatorHooks
{
return {
...tap,
- fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) =>
+ fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) =>
{
if (ctx.emulator === tap.emulator)
{
diff --git a/src/bun/api/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts
similarity index 65%
rename from src/bun/api/hooks/games.ts
rename to src/packages/gameflow-sdk/hooks/games.ts
index fb94e71..314b138 100644
--- a/src/bun/api/hooks/games.ts
+++ b/src/packages/gameflow-sdk/hooks/games.ts
@@ -1,29 +1,32 @@
-import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
+
+import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots, DownloadLookupEntry, DownloadLookupDetails, DownloadsLookupFilterValues, DownloadsLookupFilter } from '../shared';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
-export class GameHooks
+export default class GameHooks
{
+ /** Build commands the game can be launched with. */
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
source: string | null;
sourceId: string | null;
id: FrontEndId;
systemSlug: string;
gamePath: string | null,
+ /** The glob pattern for the main executable of the game */
mainGlob?: string | null,
}], CommandEntry[] | Error | undefined>(['ctx']);
/** override the launch command for an emulator
- * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
- * @param ctx.emulator The emulator ID if any
- * @param ctx.game.source The source of the game
- * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
* @returns The argument list to be used when running the emulator.
* If no emulator bin in the command entry is found the actual command will be used as the bin.
*/
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
+ /** The auto generated command for example based on the ES-DE listing */
autoValidCommand: CommandEntry;
+ /** Don't actually launch just see if it can be launched */
dryRun: boolean,
game: {
+ /** The source of the game */
source?: string;
+ /** The ID of the source. This could be for example the ROMM ID the game was */
sourceId?: string;
id: FrontEndId;
platformSlug?: string;
@@ -40,34 +43,36 @@ export class GameHooks
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
/**
* Fetches and returns a list of games converted to frontend.
- * @param ctx.localGameIds This is local game ids in the format '@'
*/
fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType;
games: FrontEndGameTypeWithIds[];
}]>(['ctx']);
+ /** Return all filters the users can apply for a give source. */
fetchFilters = new AsyncSeriesHook<[ctx: {
source?: string;
filters: FrontEndFilterSets;
}]>(['ctx']);
+ /** Get game metadata */
fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string;
localGame?: FrontEndGameTypeDetailed;
id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
+ /** Search for a given game based on the igdb id or ra id. */
searchGame = new AsyncSeriesBailHook<[ctx: {
source: string;
igdb_id?: number;
ra_id?: number;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
- /** Get download file URLs
- * @param ctx.checksum Check if file already exists using checksums
- */
+ /** Get download file URLs */
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
+ /** If there are multiple downloads, use the one with same ID */
downloadId?: string;
}], DownloadInfo[] | undefined>(['ctx']);
+ /** Get the paths to rom files. This is mainly used for emulator js. */
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
source: string;
id: string;
@@ -85,6 +90,7 @@ export class GameHooks
source: string;
id: string;
}], FrontEndPlatformType | undefined>(['ctx']);
+ /** Lookup a given platform with a given slug or id. This may or may not exist. */
platformLookup = new AsyncSeriesBailHook<[ctx: {
source?: string;
id?: string;
@@ -95,10 +101,32 @@ export class GameHooks
name?: string;
family_name?: string;
} | undefined>(['ctx']);
- gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']);
+ /** Lookup downloads based on a search pattern.
+ * This is just downloads. Doesn't actually have to be a game.
+ * This is mainly used to manually add games from outside sources */
+ downloadsLookup = new AsyncSeriesWaterfallHook<[matches: Map, ctx: {
+ page?: number;
+ rows?: number;
+ } & DownloadsLookupFilter]>(['matches', 'ctx']);
+ /** List all available filters */
+ downloadsLookupFilters = new AsyncSeriesHook<[ctx: {
+ filters: DownloadsLookupFilterValues;
+ }]>(['ctx']);
+ /** Look for the files for a download the user can pick from */
+ downloadLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], DownloadLookupDetails | undefined>(['ctx']);
+ /** Look up game metadata based on a search */
+ gameLookup = new AsyncSeriesWaterfallHook<[matches: Map, ctx: {
+ source?: string,
+ id?: string;
+ search?: string;
+ }]>(['matches', 'ctx']);
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);
+ /** Called before the game is played. */
prePlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
@@ -110,20 +138,25 @@ export class GameHooks
};
}]>(["ctx"]);
/**
- * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay
- * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone
+ * Called after the game process has finished.
*/
postPlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
- saveFolderSlots?: Record;
+ saveFolderSlots?: SaveSlots;
+ /** Auto detected changed files. This is mainly used to see what changed during gameplay */
changedSaveFiles: { subPath: string, cwd: string; }[],
+ /** This will be final valid changes to be saved using save integrations like rclone */
validChangedSaveFiles: Record,
+ /** The command that was used to launch the game */
command: CommandEntry;
gameInfo: {
platformSlug?: string;
};
}]>(["ctx"]);
+ /** Called after game install
+ * This includes game being downloaded and registered in the database.
+ */
postInstall = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
diff --git a/src/bun/api/hooks/store.ts b/src/packages/gameflow-sdk/hooks/store.ts
similarity index 77%
rename from src/bun/api/hooks/store.ts
rename to src/packages/gameflow-sdk/hooks/store.ts
index de889d1..c7f43e5 100644
--- a/src/bun/api/hooks/store.ts
+++ b/src/packages/gameflow-sdk/hooks/store.ts
@@ -1,7 +1,7 @@
-import { EmulatorDownloadInfoType } from "@/shared/constants";
+import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared";
import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
-export class StoreHooks
+export default class StoreHooks
{
fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']);
fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']);
diff --git a/src/packages/gameflow-sdk/index.ts b/src/packages/gameflow-sdk/index.ts
new file mode 100644
index 0000000..c78c757
--- /dev/null
+++ b/src/packages/gameflow-sdk/index.ts
@@ -0,0 +1,93 @@
+import z from "zod";
+import { GameflowHooks } from "./hooks/app";
+import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared";
+import { $ZodRegistry } from "zod/v4/core";
+import Conf from "conf";
+import { EventEmitter } from 'node:events';
+import { TaskQueue } from "./task-queue";
+
+export * from "./hooks/app";
+export * from "./task-queue";
+
+export interface AppEventMap
+{
+ exitapp: [];
+ notification: [FrontendNotification];
+ focus: [];
+}
+
+export const PluginContextSchema = z.object({
+ hooks: z.instanceof(GameflowHooks)
+});
+
+export const PluginLoadingContextSchema = z.object({
+ setProgress: z.function().input([z.number(), z.string()]).output(z.void()),
+ config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"),
+ zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"),
+ app: z.object({
+ config: z.instanceof(Conf),
+ events: z.instanceof(EventEmitter),
+ taskQueue: z.instanceof(TaskQueue)
+ })
+}).extend(PluginContextSchema.shape);
+
+export const PluginDescriptionSchema = z.object({
+ name: z.string(),
+ displayName: z.string().optional(),
+ version: z.string(),
+ description: z.string().optional(),
+ icon: z.url().optional().describe("Can be an external URL to an image or a data url"),
+ keywords: z.array(z.string()).optional(),
+ peerDependencies: z.record(z.string(), z.string()).optional(),
+ category: z.string().default("other"),
+ main: z.string().describe("The main entry. It must export a default class implementing PluginType"),
+ canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user"),
+ autoUpdate: z.boolean().optional().describe("Should the plugin auto update to latest version")
+});
+
+export const PluginSchema = z.object({
+ load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())).describe("Called when the plugin is loaded or reloaded"),
+ cleanup: z.function().output(z.promise(z.void())).optional().describe("Called when the plugin is unloaded or before it's reloaded"),
+ settingsSchema: z.instanceof(z.ZodObject).optional().describe("The settings schema. Gameflow will show settings in the UI."),
+ settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(),
+ eventsNames: z.object({
+ id: z.string(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ action: z.string()
+ }).array().optional().describe("Events will be called when the user presses the button in plugin settings. Each event creates a button."),
+ onEvent: z.function().input([z.string()]).output(z.object({
+ openTab: z.string().optional(),
+ reload: z.boolean().optional()
+ }).or(z.record(z.string(), z.any()))).optional()
+});
+
+export const ActiveGameSchema = z.object({
+ process: z.any().optional(),
+ gameId: z.object({ id: z.string(), source: z.string() }),
+ source: z.string().optional(),
+ sourceId: z.string().optional(),
+ name: z.string(),
+ command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() })
+});
+
+export const EmulatorPostInstallContextSchema = z.object({
+ emulator: z.string(),
+ emulatorPackage: EmulatorPackageSchema.optional(),
+ path: z.string(),
+ update: z.boolean(),
+ info: EmulatorDownloadInfoSchema,
+});
+
+export type ActiveGameType = z.infer;
+export type PluginDescriptionType = z.infer;
+export type PluginContextType = z.infer;
+export type PluginLoadingContextType = Record> = z.infer & {
+ config: Conf;
+};
+export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & {
+ load: (ctx: PluginLoadingContextType) => Promise;
+ settingsMigrations?: Record) => void>;
+};
+export type EmulatorPostInstallContextType = z.infer;
+
diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json
new file mode 100644
index 0000000..4e4ce28
--- /dev/null
+++ b/src/packages/gameflow-sdk/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@simeonradivoev/gameflow-sdk",
+ "version": "1.6.0",
+ "types": "index.d.ts",
+ "description": "plugin SDK for the Gameflow Deck Launcher",
+ "exports": {
+ ".": "./index.ts",
+ "./shared": "./shared.ts"
+ },
+ "bin": {
+ "gameflow-build": "build.ts"
+ },
+ "peerDependencies": {
+ "7zip-bin": "^5.2.0",
+ "@auth/core": "^0.34.3",
+ "cheerio": "^1.2.0",
+ "conf": "^15.1.0",
+ "drizzle-orm": "^0.45.2",
+ "elysia": "^1.4.28",
+ "fs-extra": "^11.3.5",
+ "get-folder-size": "^5.0.0",
+ "ini": "^6.0.0",
+ "jimp": "^1.6.1",
+ "mustache": "^4.2.0",
+ "node-7z": "^3.0.0",
+ "node-disk-info": "^1.3.0",
+ "node-downloader-helper": "^2.1.11",
+ "node-stream-zip": "^1.15.0",
+ "node-unrar-js": "^2.0.2",
+ "open": "^11.0.0",
+ "p-queue": "^9.2.0",
+ "pathe": "^2.0.3",
+ "slugify": "^1.6.9",
+ "smol-toml": "^1.6.1",
+ "tapable": "^2.3.3",
+ "unzip-stream": "^0.3.4",
+ "zod": "^4.4.3"
+ },
+ "keywords": [
+ "gameflow",
+ "sdk"
+ ]
+}
\ No newline at end of file
diff --git a/src/packages/gameflow-sdk/sdk.tsconfig.json b/src/packages/gameflow-sdk/sdk.tsconfig.json
new file mode 100644
index 0000000..707544a
--- /dev/null
+++ b/src/packages/gameflow-sdk/sdk.tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": [
+ "ES2024"
+ ],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "strict": true,
+ "outDir": "../../dist-sdk",
+ "types": [
+ "node"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts
new file mode 100644
index 0000000..96ddb01
--- /dev/null
+++ b/src/packages/gameflow-sdk/shared.ts
@@ -0,0 +1,710 @@
+import * as z from "zod";
+
+export const settingRegistry = z.registry<{
+ dev?: boolean;
+}>();
+
+export const SettingsSchema = z.object({
+ rommAddress: z.url().optional(),
+ rommUser: z.string().default('admin').optional(),
+ windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
+ windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
+ downloadPath: z.string(),
+ launchInFullscreen: z.boolean().default(true),
+ disabledPlugins: z.array(z.string()).default([]),
+ emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'),
+ emulatorWidescreen: z.boolean().default(true)
+}); export const LocalSettingsSchema = z.object({
+ backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }),
+ backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }),
+ theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }),
+ soundEffects: z.boolean().default(true).meta({ title: "Sounds" }),
+ soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }),
+ hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }),
+ showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }),
+ showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }),
+ useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }),
+ autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" })
+});
+export const GameListFilterSchema = z.object({
+ platform_source: z.string().optional(),
+ platform_slug: z.string().optional(),
+ platform_id: z.coerce.number().optional(),
+ collection_id: z.coerce.number().optional(),
+ collection_source: z.string().optional(),
+ limit: z.coerce.number().optional(),
+ search: z.string().optional(),
+ offset: z.coerce.number().optional(),
+ source: z.string().optional(),
+ localOnly: z.coerce.boolean().optional(),
+ orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(),
+ age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
+ genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
+ keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
+});
+export const DownloadSourceSchema = z.object({
+ id: z.string(),
+ name: z.string()
+});
+export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
+export type GameListFilterType = z.infer;
+export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() });
+export type DirType = z.infer;
+export const CustomEmulatorSchema = z.record(z.string(), z.string());
+export const GithubManifestSchema = z.object({
+ sha: z.hash('sha1'),
+ url: z.url(),
+ tree: z.array(z.object({
+ path: z.string(),
+ mode: z.string(),
+ type: z.enum(['blob', 'tree']),
+ sha: z.hash('sha1'),
+ url: z.url()
+ }))
+});
+export const StoreGameSaveSchema = z.object({
+ cwd: z.string(),
+ globs: z.string().array()
+});
+export const StoreDownloadSchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal('direct'),
+ url: z.url(),
+ name: z.string().optional(),
+ system: z.string(),
+ main: z.string().optional(),
+ saves: z.record(z.string(), StoreGameSaveSchema).optional()
+ }),
+ z.object({
+ type: z.literal("itch"),
+ path: z.string(),
+ name: z.string().optional(),
+ system: z.string(),
+ saves: z.record(z.string(), StoreGameSaveSchema).optional()
+ })
+]);
+export const NewGameSchema = z.object({
+ name: z.string(),
+ summary: z.string(),
+ genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, {
+ message: "Must be a comma-separated list",
+ })
+});
+export const StoreGameSchema = z.object({
+ name: z.string(),
+ description: z.string(),
+ version: z.string(),
+ homepage: z.string().optional(),
+ keywords: z.string().array().optional(),
+ genres: z.string().array().optional(),
+ companies: z.string().array().optional(),
+ screenshots: z.string().array().optional(),
+ covers: z.string().array().optional(),
+ igdb_id: z.number().optional(),
+ ra_id: z.number().optional(),
+ sgdb_id: z.number().optional(),
+ first_release_date: z.union([z.number(), z.date()]).optional(),
+ player_count: z.string().optional(),
+ saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(),
+ downloads: z.record(z.string(), StoreDownloadSchema)
+});
+export const EmulatorPackageSchema = z.object({
+ name: z.string(),
+ description: z.string(),
+ homepage: z.url(),
+ logo: z.url(),
+ type: z.enum(['emulator']),
+ os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
+ keywords: z.array(z.string()).optional(),
+ downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal(['github', 'gitlab']),
+ pattern: z.string(),
+ path: z.string(),
+ bin: z.string().optional()
+ }),
+ z.object({
+ type: z.literal('direct'),
+ url: z.url(),
+ bin: z.string().optional()
+ }),
+ z.object({
+ type: z.literal('scoop'),
+ url: z.url(),
+ bin: z.string().optional()
+ })
+ ]))).optional(),
+ systems: z.array(z.string()),
+ bios: z.literal(["required", "optional"]).optional()
+});
+export const ScoopPackageSchema = z.object({
+ version: z.string(),
+ url: z.url().optional(),
+ description: z.string(),
+ bin: z.string().optional(),
+ architecture: z.record(z.string(), z.object({
+ url: z.url(),
+ hash: z.string().optional(),
+ extract_dir: z.string().optional()
+ })).optional()
+});
+export const SystemInfoSchema = z.object({
+ battery: z.object({
+ percent: z.number(),
+ isCharging: z.boolean(),
+ acConnected: z.boolean(),
+ hasBattery: z.boolean()
+ }),
+ wifiConnections: z.array(z.object({ signalLevel: z.number() })),
+ bluetoothDevices: z.array(z.object({ connected: z.boolean() }))
+});
+export const GithubReleaseSchema = z.object({
+ id: z.number(),
+ tag_name: z.string().optional(),
+ url: z.url(),
+ body: z.string(),
+ assets: z.array(z.object({
+ name: z.string(),
+ browser_download_url: z.url(),
+ content_type: z.string().optional()
+ }))
+});
+export const EmulatorDownloadInfoSchema = z.object({
+ id: z.string(),
+ version: z.string().optional(),
+ url: z.url().optional(),
+ description: z.string().optional(),
+ downloadDate: z.coerce.date(),
+ type: z.string()
+});
+export const PluginEntrySchema = z.object({
+ downloads: z.object({
+ monthly: z.number(),
+ weekly: z.number()
+ }),
+ searchScore: z.number(),
+ installed: z.boolean(),
+ update: z.object({ from: z.string() }).optional(),
+ package: z.object({
+ name: z.string(),
+ keywords: z.string().array(),
+ version: z.string(),
+ description: z.string().optional(),
+ sanitized_name: z.string(),
+ license: z.string().optional(),
+ publisher: z.object({
+ email: z.string(),
+ username: z.string(),
+ trustedPublisher: z.object({
+ id: z.string(),
+ oidcConfigId: z.string()
+ }).optional()
+ }),
+ date: z.coerce.date(),
+ links: z.object({
+ homepage: z.string().optional(),
+ repository: z.string().optional(),
+ bugs: z.string().optional(),
+ npm: z.url()
+ })
+ })
+});
+export const PluginBunDetailsSchema = z.object({
+ name: z.string(),
+ keywords: z.string().array(),
+ version: z.string(),
+ author: z.object({ name: z.string().optional() }).optional(),
+ license: z.string().optional(),
+ devDependencies: z.record(z.string(), z.string()).optional(),
+ dependencies: z.record(z.string(), z.string()).optional(),
+ maintainers: z.object({ name: z.string() }).array().optional(),
+ dist: z.object({ unpackedSize: z.number() }),
+ description: z.string().optional(),
+ _npmUser: z.object({ name: z.string() }).optional()
+});
+export type EmulatorPackageType = z.infer;
+export type StoreGameType = z.infer;
+export type StoreDownloadType = z.infer;
+export type SettingsType = z.infer;
+export type LocalSettingsType = z.infer;
+export const PlatformSchema = z.object({ slug: z.string() });
+export type SystemInfoType = z.infer;
+export type EmulatorDownloadInfoType = z.infer;
+export type DownloadSourceType = z.infer;
+export type PluginEntryType = z.infer;
+export type PluginBunDetailsType = z.infer;
+
+export interface SaveFileChange
+{
+ subPath: string | string[];
+ isGlob?: true;
+ cwd: string;
+ shared: boolean;
+ fixedSize?: boolean;
+}
+
+export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded';
+
+export interface EmulatorSourceEntryType
+{
+ binPath: string;
+ rootPath?: string;
+ type: EmulatorSourceType;
+ /** Does the emulator exist in the file system */
+ exists: boolean;
+}
+
+export interface FrontEndEmulator
+{
+ name: string;
+ source: string;
+ logo: string;
+ systems: EmulatorSystem[];
+ description?: string;
+ gameCount: number;
+ validSources: EmulatorSourceEntryType[];
+ integrations: EmulatorSupport[];
+}
+
+export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; }
+
+export interface FrontEndEmulatorDetailedDownload
+{
+ name: string;
+ type: string | undefined;
+ version?: string;
+}
+
+export interface FrontEndEmulatorDetailed extends FrontEndEmulator
+{
+ homepage: string;
+ description: string;
+ downloads: FrontEndEmulatorDetailedDownload[];
+ keywords?: string[];
+ screenshots: string[];
+ biosRequirement?: "required" | "optional";
+ bios?: string[];
+ storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; };
+}
+
+export interface FrontEndGameTypeDetailedAchievement
+{
+ id: string;
+ title: string;
+ description?: string;
+ date?: Date;
+ date_hardcode?: Date;
+ badge_url?: string;
+ display_order: number;
+ type?: string;
+}
+
+export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
+{
+
+}
+
+export interface FrontEndGameTypeDetailed extends Exclude
+{
+ summary: string | null;
+ fs_size_bytes: number | null;
+ missing: boolean;
+ local: boolean;
+ version?: string | null;
+ version_system?: string | null;
+ version_source?: string | null;
+ metadata: FrontEndGameMetadataDetailed,
+ emulators?: FrontEndGameTypeDetailedEmulator[],
+ achievements?: {
+ unlocked: number;
+ total: number;
+ entires: FrontEndGameTypeDetailedAchievement[];
+ };
+};
+
+export interface Drive
+{
+ parent: string | null;
+ device: string;
+ label: string;
+ mountPoint: string | null;
+ type: string;
+ size: number;
+ used: number;
+ isRemovable: boolean;
+ interfaceType: string | null;
+ hasWriteAccess: boolean;
+ hasReadAccess: boolean;
+}
+
+export interface DownloadsDrive
+{
+ device: string;
+ label: string;
+ mountPoint: string | null;
+ isRemovable: boolean;
+ size: number;
+ used: number;
+ isCurrentlyUsed: boolean;
+ unusableReason: 'not_enough_space' | 'already_used' | null;
+}
+
+export interface FrontendNotification
+{
+ title?: string;
+ message: string;
+ type: 'success' | 'error' | 'info' | 'custom';
+ icon?: "save" | "upload" | "clock";
+ duration?: number;
+}
+
+export interface CommandEntry
+{
+ /** The ID of the command. Could be just an index or a string */
+ id: string | number;
+ /** The front end label for the command. Mainly gotten from ES-DE list */
+ label?: string;
+ /** Compiled command to be executed */
+ command: string | string[];
+ /** Environment variables */
+ env?: Record,
+ /** The path the spawned process will start at */
+ startDir?: string;
+ /** Is the command valid, for example does the executable exists */
+ valid: boolean;
+ /** Run the command as shell. Defaults is true */
+ shell?: boolean;
+ /** For what emulator is the command */
+ emulator?: string;
+ /** Where the emulator came from */
+ emulatorSource?: EmulatorSourceType;
+ /** Metadata for the command */
+ metadata: {
+ romPath?: string;
+ emulatorBin?: string;
+ /** The root directory of the emulator */
+ emulatorDir?: string;
+ };
+}
+
+export interface FrontEndId
+{
+ id: string;
+ source: string;
+}
+
+// Stuff stored in the local sqlite metadata field
+export interface LocalGameMetadata
+{
+ genres?: string[],
+ companies?: string[],
+ game_modes?: string[],
+ age_ratings?: string[];
+ player_count?: string;
+ first_release_date?: number;
+ average_rating?: number;
+}
+
+export interface FrontEndPlatformType
+{
+ id: FrontEndId;
+ slug: string;
+ name: string;
+ family_name?: string | null;
+ path_cover: string | null;
+ game_count: number;
+ updated_at: Date;
+ hasLocal: boolean;
+ paths_screenshots: string[];
+}
+
+export interface FrontEndGameTypeWithIds extends FrontEndGameType
+{
+ igdb_id: number | null;
+ ra_id: number | null;
+}
+
+export interface FrontEndFilterSets
+{
+ age_ratings: Set,
+ player_counts: Set,
+ languages: Set,
+ companies: Set,
+ genres: Set;
+}
+
+export interface FrontEndFilterLists
+{
+ age_ratings: string[],
+ player_counts: string[],
+ languages: string[],
+ companies: string[],
+ genres: string[];
+}
+
+export interface FrontEndGameMetadata
+{
+ first_release_date: Date | null;
+}
+
+export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata
+{
+ genres: string[],
+ companies: string[],
+ game_modes: string[],
+ age_ratings: string[];
+ player_count: string | null;
+ average_rating: number | null;
+}
+
+export interface FrontEndGameType
+{
+ platform_display_name: string | null,
+ path_platform_cover: string | null;
+ id: FrontEndId,
+ source: string | null,
+ source_id: string | null,
+ path_fs: string | null,
+ path_covers: string[],
+ last_played: Date | null,
+ updated_at: Date,
+ metadata: FrontEndGameMetadata,
+ slug: string | null,
+ name: string | null,
+ platform_id: number | null,
+ platform_slug: string | null,
+ paths_screenshots: string[];
+};
+
+export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued';
+
+export interface GameInstallProgress
+{
+ progress?: number;
+ status?: GameStatusType;
+ details?: string;
+ commands?: CommandEntry[];
+ error?: any;
+}
+
+export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
+export type GameInstallProgressEvent = 'refresh';
+
+export interface FrontEndJob
+{
+ id: string;
+ data: any;
+ progress: number;
+ state?: string;
+ status: string;
+}
+
+export interface FrontendPlugin
+{
+ name: string;
+ displayName?: string;
+ description?: string;
+ category: string;
+ enabled: boolean;
+ canDisable: boolean;
+ canUninstall: boolean;
+ source: PluginSourceType;
+ hasSettings: boolean;
+ version: string;
+ icon?: string;
+ update?: PluginUpdateCheck;
+}
+
+export interface PluginUpdateCheck
+{
+ current: string;
+ new: string;
+}
+
+export type PluginSourceType = "builtin" | "store";
+
+export type KeysWithValueAssignableTo = {
+ [K in keyof T]: Exclude extends Value ? K : never;
+}[keyof T];
+
+export interface DownloadInfo
+{
+ id: string;
+ screenshotUrls: string[];
+ coverUrl: string;
+ platform?: DownloadPlatform;
+ slug?: string;
+ path_fs?: string;
+ main_glob?: string;
+ summary?: string;
+ name: string;
+ last_played?: Date;
+ igdb_id?: number;
+ ra_id?: number;
+ source_id: string;
+ system_slug: string;
+ extract_path?: string;
+ metadata?: any;
+ files: DownloadFileEntry[];
+ auth?: string;
+ version?: string;
+ version_source?: string;
+ version_system?: string;
+}
+
+export interface DownloadPlatform
+{
+ id: string;
+ source: string;
+ igdb_id?: number;
+ igdb_slug?: string;
+ ra_id?: number;
+ moby_id?: number;
+ slug: string;
+ name: string;
+ /** Like Sony or Nintendo */
+ family_name?: string;
+}
+
+export interface DownloadFileEntry
+{
+ url: URL;
+ /** The path of the file, excluding the name */
+ file_path: string;
+ /** Just the name of the file including the extension */
+ file_name: string;
+ /** Checksum of the file */
+ sha1?: string;
+ /** Size in bytes */
+ size?: number;
+}
+
+export interface LocalDownloadFileEntry extends DownloadFileEntry
+{
+ /** Exists on the file system */
+ exists: boolean;
+ /** Matches the checksum */
+ matches: boolean;
+}
+
+export interface FrontEndCollection
+{
+ id: FrontEndId;
+ name: string;
+ description: string;
+ path_platform_cover: string | null;
+ game_count: number;
+}
+
+export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config";
+
+export interface EmulatorSupport
+{
+ id: string;
+ source?: EmulatorSourceEntryType;
+ supportLevel?: "partial" | "full";
+ capabilities?: EmulatorCapabilities[];
+}
+
+export interface GameLookup
+{
+ source: string;
+ id: string;
+ coverUrl: string | null | undefined;
+ slug: string | null | undefined;
+ screenshotUrls: string[];
+ name: string;
+ summary: string | null | undefined;
+ genres: string[];
+ companies: string[];
+ game_modes: string[];
+ age_ratings: string[];
+ player_count: string | undefined;
+ first_release_date: number | undefined;
+ average_rating: number | undefined;
+ keywords: string[];
+ igdb_id: number | undefined;
+ platforms: {
+ id: number;
+ name?: string | null;
+ displayName: string;
+ slug: string;
+ }[];
+}
+
+export interface DownloadLookupEntry
+{
+ source: string;
+ id: string;
+ cover_url: string | null | undefined;
+ name: string;
+ summary: string | null | undefined;
+ size: number | null | undefined;
+ date: Date | null | undefined;
+ rating: number | null | undefined;
+ view_count: number | null | undefined;
+ download_count: number | null | undefined;
+ comment_count: number | null | undefined;
+}
+
+export interface DownloadLookupDetailsFile
+{
+ id: string;
+ format: string | null | undefined;
+ mtime: Date | null | undefined;
+ size: number | null | undefined;
+ download_url: string;
+}
+
+export interface DownloadLookupDetails
+{
+ source: string;
+ id: string;
+ cover_url: string | null | undefined;
+ name: string;
+ summary: string | null | undefined;
+ date: Date | null | undefined;
+ files: DownloadLookupDetailsFile[];
+}
+
+export interface AutoSaveChange
+{
+ subPath: string;
+ cwd: string;
+}
+
+export interface AppInfoContext
+{
+ activeTaskProgress: number | null;
+}
+
+export type SaveSlots = Record;
+
+/** Jobs that are downloading stuff can implement this data interface to show up in the downloads screen */
+export interface DownloadJobData extends Partial>
+{
+ preview_url?: string | null;
+ name?: string;
+}
+
+export interface ProgressStats
+{
+ progress: number;
+ speed: number;
+ total: number;
+ downloaded: number;
+}
+
+export interface DownloadsLookupFilter
+{
+ source?: string,
+ orderBy?: string,
+ search?: string;
+ sortDirection?: "desc" | "asc";
+}
+
+export interface DownloadsLookupFilterValues
+{
+ orderBy: string[],
+ source: string[];
+}
\ No newline at end of file
diff --git a/src/bun/api/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts
similarity index 70%
rename from src/bun/api/task-queue.ts
rename to src/packages/gameflow-sdk/task-queue.ts
index bb890df..e86cebc 100644
--- a/src/bun/api/task-queue.ts
+++ b/src/packages/gameflow-sdk/task-queue.ts
@@ -1,8 +1,7 @@
-
-import { and } from 'drizzle-orm';
import EventEmitter from 'node:events';
-import z, { any } from 'zod';
+import z from 'zod';
+import { JobStatus } from './shared';
export class TaskQueue
{
@@ -10,14 +9,33 @@ export class TaskQueue
private queue?: JobContext, any, string>[] = [];
private events?: EventEmitter = new EventEmitter();
- public enqueue (id: string, job: T): T extends IJob
+ constructor()
+ {
+ // we need a default error listener or app crashes
+ this.events?.addListener('error', e =>
+ {
+ console.error(e);
+ });
+ }
+
+ public enqueue (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob
? Promise
: never
{
this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed");
- const context = new JobContext(id, this.events, job);
+ if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`);
+ if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
+ const context = new JobContext(id, this.events, job, options);
this.queue.push(context as any);
+ context.abortSignal.addEventListener('abort', () =>
+ {
+ const queueIndex = this.queue?.findIndex(c => c === context);
+ if (queueIndex !== undefined && queueIndex >= 0)
+ {
+ this.queue?.splice(queueIndex, 1);
+ }
+ });
this.events?.emit('queued', { id: context.id, job: context });
this.processQueue();
return context.promise.promise as any;
@@ -27,7 +45,24 @@ export class TaskQueue
{
if (!this.queue) return Promise.resolve();
- const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
+ let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group));
+ const next = this.queue.filter(j =>
+ {
+ if (j.job.group)
+ {
+ // Only take one task per group to be active
+ if (!activeGroupsSet.has(j.job.group))
+ {
+ activeGroupsSet.add(j.job.group);
+ return true;
+ }
+ } else
+ {
+ return true;
+ }
+
+ return false;
+ }).map((job, i) => ({ i, job }));
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
@@ -35,7 +70,7 @@ export class TaskQueue
{
job.job.start();
this.activeQueue.push(job.job);
- job.job.promise.promise.finally(() =>
+ job.job.promise.promise.catch(e => { }).finally(() =>
{
const index = this.activeQueue.indexOf(job.job);
this.activeQueue.splice(index, 1);
@@ -56,6 +91,11 @@ export class TaskQueue
return this.activeQueue.length > 0;
}
+ public hasQueued ()
+ {
+ return this.queue && this.queue.length > 0;
+ }
+
public hasActiveOfType (type: any)
{
for (const entry of this.activeQueue)
@@ -74,6 +114,38 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve();
}
+ public waitForAll ()
+ {
+ return new Promise((resolve) =>
+ {
+ if (!this.hasActive())
+ {
+ resolve(true);
+ return;
+ }
+
+ const handleEnded = () =>
+ {
+ if (!this.hasActive() && !this.hasQueued())
+ {
+ resolve(true);
+ this.events?.removeListener('ended', handleEnded);
+ this.events?.removeListener('abort', handleEnded);
+ }
+ };
+ this.events?.on('ended', handleEnded);
+ this.events?.on('abort', handleEnded);
+ });
+ }
+
+ public cancelJob (id: string)
+ {
+ const job = this.queue?.find(j => j.id === id)
+ ?? this.activeQueue?.find(j => j.id === id);
+
+ job?.abort('cancel');
+ }
+
public findJob (
id: string,
type: new (...args: any[]) => T
@@ -91,6 +163,16 @@ export class TaskQueue
return undefined as any;
}
+ public getActiveJobs ()
+ {
+ return this.activeQueue;
+ }
+
+ public getQueuedJobs ()
+ {
+ return this.queue;
+ }
+
public on (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
{
this.events?.on(event, listener);
@@ -162,6 +244,7 @@ export interface CompletedEvent extends BaseEvent
export interface IJob
{
+ /** What group does the job belong to. Grouped jobs can only have 1 active job per group */
group?: string;
start (context: JobContext, TData, TState>): Promise;
exposeData?(): TData;
@@ -202,12 +285,14 @@ export class JobContext, TData, TState extends str
private events: EventEmitter;
private abortController: AbortController;
private m_promise: PromiseWithResolvers;
+ private throwOnCancel: boolean;
private readonly m_job: T;
- constructor(id: string, events: EventEmitter, job: T)
+ constructor(id: string, events: EventEmitter, job: T, options?: { throwOnCancel?: boolean; })
{
this.m_id = id;
this.m_job = job;
+ this.throwOnCancel = options?.throwOnCancel ?? false;
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () =>
{
@@ -235,26 +320,27 @@ export class JobContext, TData, TState extends str
}
} catch (error)
{
- try
+ if (error instanceof Event)
{
- if (error instanceof Event)
+ if (error.target instanceof AbortSignal)
{
- if (error.target instanceof AbortSignal)
+ if (this.throwOnCancel)
{
-
+ this.m_promise.reject(this.abortSignal.reason);
} else
{
- console.error(error);
+ this.m_promise.resolve(undefined);
}
} else
{
console.error(error);
- this.events.emit('error', { id: this.m_id, job: this, error });
- this.error = error;
+ this.m_promise.reject(error);
}
- } finally
+ } else
{
- this.m_promise.resolve(undefined);
+ this.events.emit('error', { id: this.m_id, job: this, error });
+ this.error = error;
+ this.m_promise.reject(error);
}
} finally
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index e21bb54..3c9d776 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -1,8 +1,3 @@
-
-
-import { JSX } from 'react';
-import * as z from 'zod';
-
export const LOGIN_PORT = 5196;
export const OAUTH_REDIRECT_PORT = 5194;
export const SERVER_PORT = 5173;
@@ -15,205 +10,4 @@ export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_POR
export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`;
export const DefaultRommStaleTime = 60 * 1000; // A minute
-export interface GameMeta extends FocusParams
-{
- id: string,
- onSelect?: () => void,
- title: string,
- subtitle?: string | JSX.Element,
- previewUrls?: string | URL[];
- previewSrcset?: string;
-};
-
-export const SettingsSchema = z.object({
- rommAddress: z.url().optional(),
- rommUser: z.string().default('admin').optional(),
- windowSize: z.object({ width: z.number(), height: z.number() }).optional(),
- windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
- downloadPath: z.string(),
- launchInFullscreen: z.boolean().default(true),
- disabledPlugins: z.array(z.string()).default([]),
- emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'),
- emulatorWidescreen: z.boolean().default(true)
-});
-
-export const LocalSettingsSchema = z.object({
- backgroundBlur: z.stringbool().or(z.boolean()).default(true),
- backgroundAnimation: z.stringbool().or(z.boolean()).default(true),
- theme: z.enum(['dark', 'light', 'auto']).default('auto'),
- soundEffects: z.boolean().default(true),
- soundEffectsVolume: z.number().min(0).max(100).default(50),
- hapticsEffects: z.boolean().default(true),
- showRouterDevOptions: z.boolean().default(false),
- showQueryDevOptions: z.boolean().default(false),
-});
-
-export const GameListFilterSchema = z.object({
- platform_source: z.string().optional(),
- platform_slug: z.string().optional(),
- platform_id: z.coerce.number().optional(),
- collection_id: z.coerce.number().optional(),
- collection_source: z.string().optional(),
- limit: z.coerce.number().optional(),
- search: z.string().optional(),
- offset: z.coerce.number().optional(),
- source: z.string().optional(),
- localOnly: z.coerce.boolean().optional(),
- orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(),
- age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
- genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
- keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
-});
-
-export const DownloadSourceSchema = z.object({
- id: z.string(),
- name: z.string()
-});
-
-export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
-
-export type GameListFilterType = z.infer;
-
-export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() });
-export type DirType = z.infer;
-
-export const CustomEmulatorSchema = z.record(z.string(), z.string());
-
-export const GithubManifestSchema = z.object({
- sha: z.hash('sha1'),
- url: z.url(),
- tree: z.array(z.object({
- path: z.string(),
- mode: z.string(),
- type: z.enum(['blob', 'tree']),
- sha: z.hash('sha1'),
- url: z.url()
- }))
-});
-
-export const StoreGameSaveSchema = z.object({
- cwd: z.string(),
- globs: z.string().array()
-});
-
-export const StoreDownloadSchema = z.discriminatedUnion('type', [
- z.object({
- type: z.literal('direct'),
- url: z.url(),
- name: z.string().optional(),
- system: z.string(),
- main: z.string().optional(),
- saves: z.record(z.string(), StoreGameSaveSchema).optional()
- }),
- z.object({
- type: z.literal("itch"),
- path: z.string(),
- name: z.string().optional(),
- system: z.string(),
- saves: z.record(z.string(), StoreGameSaveSchema).optional()
- })
-]);
-
-export const StoreGameSchema = z.object({
- name: z.string(),
- description: z.string(),
- version: z.string(),
- homepage: z.string().optional(),
- keywords: z.string().array().optional(),
- genres: z.string().array().optional(),
- companies: z.string().array().optional(),
- screenshots: z.string().array().optional(),
- covers: z.string().array().optional(),
- igdb_id: z.number().optional(),
- ra_id: z.number().optional(),
- sgdb_id: z.number().optional(),
- first_release_date: z.union([z.number(), z.date()]).optional(),
- player_count: z.string().optional(),
- saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(),
- downloads: z.record(z.string(), StoreDownloadSchema)
-});
-
-export const EmulatorPackageSchema = z.object({
- name: z.string(),
- description: z.string(),
- homepage: z.url(),
- logo: z.url(),
- type: z.enum(['emulator']),
- os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
- keywords: z.array(z.string()).optional(),
- downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [
- z.object({
- type: z.literal(['github', 'gitlab']),
- pattern: z.string(),
- path: z.string(),
- bin: z.string().optional()
- }),
- z.object({
- type: z.literal('direct'),
- url: z.url(),
- bin: z.string().optional()
- }),
- z.object({
- type: z.literal('scoop'),
- url: z.url(),
- bin: z.string().optional()
- })
- ]))).optional(),
- systems: z.array(z.string()),
- bios: z.literal(["required", "optional"]).optional()
-});
-
-export const ScoopPackageSchema = z.object({
- version: z.string(),
- url: z.url().optional(),
- description: z.string(),
- bin: z.string().optional(),
- architecture: z.record(z.string(), z.object({
- url: z.url(),
- hash: z.string().optional(),
- extract_dir: z.string().optional()
- })).optional()
-});
-
-export const SystemInfoSchema = z.object({
- battery: z.object({
- percent: z.number(),
- isCharging: z.boolean(),
- acConnected: z.boolean(),
- hasBattery: z.boolean()
-
- }),
- wifiConnections: z.array(z.object({ signalLevel: z.number() })),
- bluetoothDevices: z.array(z.object({ connected: z.boolean() }))
-});
-
-export const GithubReleaseSchema = z.object({
- id: z.number(),
- tag_name: z.string().optional(),
- url: z.url(),
- body: z.string(),
- assets: z.array(z.object({
- name: z.string(),
- browser_download_url: z.url(),
- content_type: z.string().optional()
- }))
-});
-
-export const EmulatorDownloadInfoSchema = z.object({
- id: z.string(),
- version: z.string().optional(),
- url: z.url().optional(),
- description: z.string().optional(),
- downloadDate: z.coerce.date(),
- type: z.string()
-});
-
-export type EmulatorPackageType = z.infer;
-export type StoreGameType = z.infer;
-export type StoreDownloadType = z.infer;
-export type SettingsType = z.infer;
-export type LocalSettingsType = z.infer;
-export const PlatformSchema = z.object({ slug: z.string() });
-export type SystemInfoType = z.infer;
-export type EmulatorDownloadInfoType = z.infer;
-export type DownloadSourceType = z.infer;
+export const PluginRegistry = process.env.STORE_REGISTRY ?? "https://registry.npmjs.org";
\ No newline at end of file
diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts
deleted file mode 100644
index a490edb..0000000
--- a/src/shared/types..d.ts
+++ /dev/null
@@ -1,347 +0,0 @@
-declare type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded';
-
-declare interface EmulatorSourceEntryType
-{
- binPath: string;
- rootPath?: string;
- type: EmulatorSourceType;
- exists: boolean;
-}
-
-declare interface FrontEndEmulator
-{
- name: string;
- source: string;
- logo: string;
- systems: EmulatorSystem[];
- description?: string;
- gameCount: number;
- validSources: EmulatorSourceEntryType[];
- integrations: EmulatorSupport[];
-}
-
-declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; }
-
-declare interface FrontEndEmulatorDetailedDownload
-{
- name: string;
- type: string | undefined;
- version?: string;
-}
-
-declare interface FrontEndEmulatorDetailed extends FrontEndEmulator
-{
- homepage: string;
- description: string;
- downloads: FrontEndEmulatorDetailedDownload[];
- keywords?: string[];
- screenshots: string[];
- biosRequirement?: "required" | "optional";
- bios?: string[];
- storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; };
-}
-
-declare interface FrontEndGameTypeDetailedAchievement
-{
- id: string;
- title: string;
- description?: string;
- date?: Date;
- date_hardcode?: Date;
- badge_url?: string;
- display_order: number;
- type?: string;
-}
-
-declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
-{
-
-}
-
-declare interface FrontEndGameTypeDetailed extends Exclude