feat: Implemented dolphin integration
This commit is contained in:
parent
edbc390d14
commit
a69147a4f7
24 changed files with 220 additions and 59 deletions
|
|
@ -20,6 +20,7 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getter();
|
const data = await getter();
|
||||||
|
if (data === undefined) return data;
|
||||||
|
|
||||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { cores } from '../../emulatorjs/emulatorjs';
|
||||||
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
import { EmulatorPackageType } from '@/shared/constants';
|
import { EmulatorPackageType } from '@/shared/constants';
|
||||||
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
|
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
|
||||||
|
import { getOrCached } from '../../cache';
|
||||||
|
import { getScoopPackage } from '../../store/services/emulatorsService';
|
||||||
|
|
||||||
export const varRegex = /%([^%]+)%/g;
|
export const varRegex = /%([^%]+)%/g;
|
||||||
export const assignRegex = /(%\w+%)=(\S+) /g;
|
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||||
|
|
@ -285,11 +287,27 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
|
||||||
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
|
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
|
||||||
{
|
{
|
||||||
// glob file search causes issues so do manual search
|
// glob file search causes issues so do manual search
|
||||||
const glob = new Glob(dl.pattern);
|
|
||||||
if (await fs.exists(storeEmulatorFolder))
|
if (await fs.exists(storeEmulatorFolder))
|
||||||
{
|
{
|
||||||
|
const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined;
|
||||||
|
let bin: string | undefined = (dl as any).bin;
|
||||||
|
if (!bin && dl.type === 'scoop')
|
||||||
|
{
|
||||||
|
const data = await getScoopPackage(id, dl.url);
|
||||||
|
|
||||||
|
if (data)
|
||||||
|
{
|
||||||
|
bin = data.bin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const files = (await fs.readdir(storeEmulatorFolder))
|
const files = (await fs.readdir(storeEmulatorFolder))
|
||||||
.filter(f => glob.match(f));
|
.filter(f =>
|
||||||
|
{
|
||||||
|
if (glob && glob.match(f)) return true;
|
||||||
|
if (bin && f === bin) return true;
|
||||||
|
});
|
||||||
|
|
||||||
return files.map(f => path.join(storeEmulatorFolder, f));
|
return files.map(f => path.join(storeEmulatorFolder, f));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ export class GameHooks
|
||||||
id: number;
|
id: number;
|
||||||
};
|
};
|
||||||
}], string[] | undefined>(['ctx']);
|
}], string[] | undefined>(['ctx']);
|
||||||
|
/**
|
||||||
|
* Is the given emulator for the given command supported
|
||||||
|
* @returns The possible value is if it can support it but not right now. To show grayed out icon.
|
||||||
|
*/
|
||||||
|
emulatorLaunchSupport = new SyncBailHook<[ctx: {
|
||||||
|
emulator: string;
|
||||||
|
source?: EmulatorSourceEntryType;
|
||||||
|
}], { id: string; possible: boolean; } | undefined>(['ctx']);
|
||||||
/**
|
/**
|
||||||
* Fetches and returns a list of games converted to frontend.
|
* Fetches and returns a list of games converted to frontend.
|
||||||
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
|
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Downloader } from "@/bun/utils/downloader";
|
||||||
import { ensureDir, move } from "fs-extra";
|
import { ensureDir, move } from "fs-extra";
|
||||||
import { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import { path7za } from "7zip-bin";
|
import { path7za } from "7zip-bin";
|
||||||
|
import { getScoopPackage } from "../store/services/emulatorsService";
|
||||||
|
|
||||||
type EmulatorDownloadStates = "download" | "extract";
|
type EmulatorDownloadStates = "download" | "extract";
|
||||||
|
|
||||||
|
|
@ -55,6 +56,34 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
||||||
} else if (validDownload.type === 'direct')
|
} else if (validDownload.type === 'direct')
|
||||||
{
|
{
|
||||||
downloadUrl = new URL(validDownload.url);
|
downloadUrl = new URL(validDownload.url);
|
||||||
|
} else if (validDownload.type === 'scoop')
|
||||||
|
{
|
||||||
|
const data = await getScoopPackage(this.emulator, validDownload.url);
|
||||||
|
let scoopDownload: URL | undefined;
|
||||||
|
if (data)
|
||||||
|
{
|
||||||
|
if (data.url)
|
||||||
|
{
|
||||||
|
scoopDownload = new URL(data.url);
|
||||||
|
} else if (data.architecture)
|
||||||
|
{
|
||||||
|
if (process.arch === 'x64' && data.architecture["64bit"])
|
||||||
|
{
|
||||||
|
scoopDownload = new URL(data.architecture["64bit"].url);
|
||||||
|
} else if (process.arch === "arm64" && data.architecture["arm64"])
|
||||||
|
{
|
||||||
|
scoopDownload = new URL(data.architecture["arm64"].url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoopDownload)
|
||||||
|
{
|
||||||
|
downloadUrl = scoopDownload;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Could not find scoop download");
|
||||||
|
}
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
throw new Error("Download Type Unsupported");
|
throw new Error("Download Type Unsupported");
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default class UpdateStoreJob implements IJob<never, never>
|
||||||
await ensureDir(storeFolder);
|
await ensureDir(storeFolder);
|
||||||
|
|
||||||
console.log("Updating Store");
|
console.log("Updating Store");
|
||||||
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
|
const proc = Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||||
cwd: storeFolder,
|
cwd: storeFolder,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
|
||||||
|
import { config, db } from "@/bun/api/app";
|
||||||
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
|
import path from 'node:path';
|
||||||
|
import desc from './package.json';
|
||||||
|
|
||||||
|
export default class DOLPHINIntegration implements PluginType
|
||||||
|
{
|
||||||
|
load (ctx: PluginContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
|
||||||
|
{
|
||||||
|
if (ctx.emulator === 'DOLPHIN')
|
||||||
|
return { id: desc.name, possible: !!ctx.source };
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
|
{
|
||||||
|
if (ctx.autoValidCommand.emulator === 'DOLPHIN' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
|
{
|
||||||
|
const args = ["--batch"];
|
||||||
|
|
||||||
|
const storageFolder = path.join(config.get('downloadPath'), "saves", 'DOLPHIN');
|
||||||
|
|
||||||
|
args.push(...[`--user=${storageFolder}`, `--exec=${ctx.autoValidCommand.metadata.romPath}`]);
|
||||||
|
args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
|
||||||
|
args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`);
|
||||||
|
args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`);
|
||||||
|
args.push(`--config=Dolphin.Interface.ConfirmStop=False`);
|
||||||
|
args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`);
|
||||||
|
args.push(`--config=Dolphin.Analytics.PermissionAsked=True`);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.dolphin",
|
||||||
|
"displayName": "DOLPHIN Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "DOLPHIN Emulator Integration",
|
||||||
|
"main": "./dolphin.ts",
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"wiiu",
|
||||||
|
"gc",
|
||||||
|
"dolphin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,14 @@ export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
load (ctx: PluginContextType)
|
load (ctx: PluginContextType)
|
||||||
{
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
|
||||||
|
{
|
||||||
|
if (ctx.emulator === 'PCSX2')
|
||||||
|
{
|
||||||
|
return { id: desc.name, possible: ctx.source?.type === 'store' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
{
|
{
|
||||||
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
load (ctx: PluginContextType)
|
load (ctx: PluginContextType)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap(desc.name, (ctx) =>
|
||||||
|
{
|
||||||
|
if (ctx.emulator === 'PPSSPP')
|
||||||
|
{
|
||||||
|
return { id: desc.name, possible: ctx.source?.type === 'store' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
{
|
{
|
||||||
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { PluginManager } from "./plugin-manager";
|
||||||
|
|
||||||
import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json';
|
import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json';
|
||||||
import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json';
|
import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json';
|
||||||
|
import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json';
|
||||||
import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json';
|
import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json';
|
||||||
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ export default async function register (pluginManager: PluginManager)
|
||||||
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
|
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
|
||||||
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
||||||
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
|
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
|
||||||
|
{ ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') },
|
||||||
{ ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') },
|
{ ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { EmulatorPackageType } from "@/shared/constants";
|
import { EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
||||||
import { emulatorsDb, plugins } from "../../app";
|
import { emulatorsDb, plugins } from "../../app";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { findExecs } from "../../games/services/launchGameService";
|
import { findExecs } from "../../games/services/launchGameService";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOrCached } from "../../cache";
|
||||||
|
|
||||||
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[])
|
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[])
|
||||||
{
|
{
|
||||||
|
|
@ -21,16 +22,32 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
||||||
systems,
|
systems,
|
||||||
gameCount,
|
gameCount,
|
||||||
validSources: execPaths,
|
validSources: execPaths,
|
||||||
integration: findEmulatorPluginIntegration(emulator.name)
|
integration: findEmulatorPluginIntegration(emulator.name, execPaths)
|
||||||
};
|
};
|
||||||
|
|
||||||
return em;
|
return em;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findEmulatorPluginIntegration (name: string)
|
export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[])
|
||||||
{
|
{
|
||||||
const lowerCaseName = name.toLowerCase();
|
const hasSupport = validSources.concat(undefined).map(s => plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s })).filter(s => !!s);
|
||||||
const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName));
|
|
||||||
if (!integration) return undefined;
|
if (hasSupport.length <= 0) return undefined;
|
||||||
return { name: integration[0], version: integration[1].description.version };
|
return { name: hasSupport[0].id, version: plugins.plugins[hasSupport[0].id]?.description.version, possible: hasSupport.some(s => s.possible) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScoopPackage (id: string, url: string)
|
||||||
|
{
|
||||||
|
const data = await getOrCached(`scoop-dl-${id}`, async () =>
|
||||||
|
{
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok)
|
||||||
|
{
|
||||||
|
return ScoopPackageSchema.parseAsync(await res.json());
|
||||||
|
}
|
||||||
|
console.error(res.statusText);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -101,14 +101,7 @@ export async function getAllStoreEmulatorPackages ()
|
||||||
const emulators = await fs.readdir(emulatorsBucket);
|
const emulators = await fs.readdir(emulatorsBucket);
|
||||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||||
|
|
||||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.parse(JSON.parse(d)));
|
||||||
{
|
|
||||||
if (e.error)
|
|
||||||
{
|
|
||||||
console.error(e.error);
|
|
||||||
}
|
|
||||||
return e.data;
|
|
||||||
}).map(e => e.data!);
|
|
||||||
|
|
||||||
return emulatesParsed;
|
return emulatesParsed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
sources: execPaths,
|
sources: execPaths,
|
||||||
biosRequirement: emulatorPackage.bios,
|
biosRequirement: emulatorPackage.bios,
|
||||||
bios: biosFiles,
|
bios: biosFiles,
|
||||||
integration: findEmulatorPluginIntegration(emulatorPackage.name)
|
integration: findEmulatorPluginIntegration(emulatorPackage.name, execPaths)
|
||||||
};
|
};
|
||||||
|
|
||||||
return emulator;
|
return emulator;
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export interface HeaderButton
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
external?: boolean;
|
external?: boolean;
|
||||||
action?: () => void;
|
action?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeaderAccount
|
export interface HeaderAccount
|
||||||
|
|
@ -247,25 +248,28 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
|
|
||||||
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
||||||
{
|
{
|
||||||
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
|
||||||
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||||
<ClockStatus />
|
<FocusContext value={focusKey}>
|
||||||
<WiFiStatus />
|
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||||
<BluetoothStatus />
|
<ClockStatus />
|
||||||
<NotificationStatus />
|
<WiFiStatus />
|
||||||
<BatteryStatus />
|
<BluetoothStatus />
|
||||||
</div>
|
<NotificationStatus />
|
||||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
<BatteryStatus />
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||||
key={b.id}
|
<div className="flex gap-2">
|
||||||
className="header-icon sm:size-10 md:size-14"
|
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||||
id={b.id}
|
key={b.id}
|
||||||
external={b.external}
|
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
|
||||||
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
|
id={b.id}
|
||||||
onAction={b.action}
|
external={b.external}
|
||||||
>{b.icon}</RoundButton>)}
|
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
|
||||||
</div>
|
onAction={b.action}
|
||||||
|
>{b.icon}</RoundButton>)}
|
||||||
|
</div>
|
||||||
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,13 +300,13 @@ export function HeaderUI (data: HeaderUIParams)
|
||||||
>
|
>
|
||||||
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
|
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
|
||||||
{data.title}
|
{data.title}
|
||||||
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", action: goToSettings, external: true }]} />
|
||||||
</header>
|
</header>
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
|
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; } & HeaderUIParams)
|
||||||
{
|
{
|
||||||
const [isStuck, setIsStuck] = useState(false);
|
const [isStuck, setIsStuck] = useState(false);
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
|
|
@ -311,7 +315,7 @@ export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<div ref={sentinelRef} className="h-0" />
|
||||||
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
|
<div ref={headerRef} className={twMerge('sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15', data.className)}>
|
||||||
<HeaderUI focusable={!isStuck} {...data} />
|
<HeaderUI focusable={!isStuck} {...data} />
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
|
|
|
||||||
10
src/mainview/components/store/InvalidStoreError.tsx
Normal file
10
src/mainview/components/store/InvalidStoreError.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { ErrorComponentProps } from "@tanstack/react-router";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Error (data: ErrorComponentProps)
|
||||||
|
{
|
||||||
|
return <div className='flex flex-col w-full gap-2 h-64 items-center justify-center'>
|
||||||
|
<div className='flex gap-2 font-bold text-2xl text-error'><TriangleAlert />Invalid Store. Update App.</div>
|
||||||
|
<div className='text-base-content/40'>{data.error.message}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -81,8 +81,8 @@ export function StoreEmulatorCard (data: {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 mt-1 h-10 items-center">
|
<div className="flex gap-1 mt-1 h-10 items-center">
|
||||||
{!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') && <div className="tooltip tooltip-primary" data-tip="Has Integration">
|
{!!data.emulator.integration && <div aria-disabled={!data.emulator.integration.possible} className="tooltip not-aria-disabled:tooltip-primary" data-tip={data.emulator.integration.possible ? "Has Integration" : "Can Integrate"}>
|
||||||
<div className="bg-primary text-primary-content rounded-full p-1"><WandSparkles /></div>
|
<div className="bg-primary in-aria-disabled:bg-base-200 text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
|
||||||
</div>}
|
</div>}
|
||||||
{data.emulator.validSources.slice(0, 3).map(s =>
|
{data.emulator.validSources.slice(0, 3).map(s =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { RPC_URL } from "@shared/constants";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react";
|
import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react";
|
||||||
import { HeaderUI } from "../../components/Header";
|
import { HeaderUI, StickyHeaderUI } from "../../components/Header";
|
||||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Shortcuts from "../../components/Shortcuts";
|
import Shortcuts from "../../components/Shortcuts";
|
||||||
|
|
@ -146,7 +146,6 @@ function RouteComponent ()
|
||||||
const [, setUpdate] = useState(0);
|
const [, setUpdate] = useState(0);
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
|
||||||
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
|
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
|
||||||
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
||||||
|
|
||||||
|
|
@ -158,7 +157,6 @@ function RouteComponent ()
|
||||||
|
|
||||||
const { shortcuts } = useShortcutContext();
|
const { shortcuts } = useShortcutContext();
|
||||||
|
|
||||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
|
||||||
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists));
|
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists));
|
||||||
|
|
||||||
const { ref: intersct } = useIntersectionObserver({
|
const { ref: intersct } = useIntersectionObserver({
|
||||||
|
|
@ -176,10 +174,7 @@ function RouteComponent ()
|
||||||
}} >
|
}} >
|
||||||
<div className="z-10">
|
<div className="z-10">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<StickyHeaderUI ref={headerRef} className="not-data-stuck:bg-base-200/40" />
|
||||||
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
|
||||||
<HeaderUI />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||||
<Details game={data} id={id} source={source} />
|
<Details game={data} id={id} source={source} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -315,8 +315,8 @@ export default function ConsoleHomeUI ()
|
||||||
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
||||||
headerButtons.push(
|
headerButtons.push(
|
||||||
{ id: "search-header-button", icon: <Search /> },
|
{ id: "search-header-button", icon: <Search /> },
|
||||||
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate() },
|
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate(), className: "focusable-error!" },
|
||||||
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => Router.navigate({ to: "/settings/accounts" }) }
|
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => router.navigate({ to: "/settings/accounts" }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
|
|
||||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
|
||||||
import { Joystick } from 'lucide-react';
|
import { Joystick, TriangleAlert } from 'lucide-react';
|
||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
|
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
|
||||||
|
|
@ -9,9 +9,11 @@ import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { storeEmulatorsQuery } from '@queries/store';
|
import { storeEmulatorsQuery } from '@queries/store';
|
||||||
|
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/emulators')({
|
export const Route = createFileRoute('/store/tab/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
errorComponent: InvalidStoreError
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
|
|
@ -22,7 +24,7 @@ function RouteComponent ()
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
const storeContext = useContext(StoreContext);
|
const storeContext = useContext(StoreContext);
|
||||||
const { data: emulators } = useQuery(storeEmulatorsQuery);
|
const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true });
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||||
import { storeGamesInfiniteQuery } from '@queries/store';
|
import { storeGamesInfiniteQuery } from '@queries/store';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
|
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/games')({
|
export const Route = createFileRoute('/store/tab/games')({
|
||||||
component: RouteComponent
|
component: RouteComponent,
|
||||||
|
errorComponent: InvalidStoreError
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const storeEmulatorsQuery = queryOptions({
|
||||||
queryKey: ['store-emulators'], queryFn: async () =>
|
queryKey: ['store-emulators'], queryFn: async () =>
|
||||||
{
|
{
|
||||||
const { data, error } = await storeApi.api.store.emulators.get();
|
const { data, error } = await storeApi.api.store.emulators.get();
|
||||||
if (error) throw error;
|
if (error) throw new Error(JSON.stringify(error.value));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
|
||||||
node: GetFocusedElement(newFocusKey)
|
node: GetFocusedElement(newFocusKey)
|
||||||
};
|
};
|
||||||
setCurrentFocusedKey(newFocusKey, focusDetails);
|
setCurrentFocusedKey(newFocusKey, focusDetails);
|
||||||
window.dispatchEvent(new CustomEvent<FocusEventDetails>('focuschanged', {
|
(GetFocusedElement(newFocusKey) ?? window).dispatchEvent(new CustomEvent<FocusEventDetails>('focuschanged', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
detail: details
|
detail: details
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,22 @@ export const EmulatorPackageSchema = z.object({
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('direct'),
|
type: z.literal('direct'),
|
||||||
url: z.url(),
|
url: z.url(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('scoop'),
|
||||||
|
url: z.url(),
|
||||||
})
|
})
|
||||||
]))).optional(),
|
]))).optional(),
|
||||||
systems: z.array(z.string()),
|
systems: z.array(z.string()),
|
||||||
bios: z.literal(["required", "optional"]).optional()
|
bios: z.literal(["required", "optional"]).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ScoopPackageSchema = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
url: z.url().optional(),
|
||||||
|
architecture: z.record(z.string(), z.object({ url: z.url(), hash: z.string().optional() })).optional()
|
||||||
|
});
|
||||||
|
|
||||||
export const SystemInfoSchema = z.object({
|
export const SystemInfoSchema = z.object({
|
||||||
battery: z.object({
|
battery: z.object({
|
||||||
percent: z.number(),
|
percent: z.number(),
|
||||||
|
|
|
||||||
3
src/shared/types..d.ts
vendored
3
src/shared/types..d.ts
vendored
|
|
@ -18,7 +18,8 @@ declare interface FrontEndEmulator
|
||||||
validSources: EmulatorSourceEntryType[];
|
validSources: EmulatorSourceEntryType[];
|
||||||
integration?: {
|
integration?: {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version?: string;
|
||||||
|
possible: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue