gameflow-deck/src/mainview/routes/store/details.emulator.$id.tsx
Simeon Radivoev 9141fb35d4
feat: Implemented link game importing
feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
2026-05-15 13:50:55 +03:00

477 lines
No EOL
23 KiB
TypeScript

import { useContext, useRef, useState } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { FloatingShortcuts } from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
import { RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots";
import { StickyHeaderUI } from "@/mainview/components/Header";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
import { HandleGoBack, scrollIntoViewHandler, useJobStatus } from "@/mainview/scripts/utils";
import toast from "react-hot-toast";
import { getErrorMessage } from "react-error-boundary";
import { emulatorStatusIcons } from "@/mainview/components/store/StoreEmulatorCard";
import StatList, { StatEntry } from "@/mainview/components/StatList";
import { GamesSection } from "@/mainview/components/store/GamesSection";
import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store";
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
import FocusTooltip from "@/mainview/components/FocusTooltip";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import { FilterUI } from "@/mainview/components/Filters";
import Markdown from "react-markdown";
import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,
async loader (ctx)
{
ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery(ctx.params.id));
ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id));
},
staticData: {
enterSound: "openDetails",
goBackSound: "returnDetails"
}
});
function HomePageLink (data: { homepage?: string; })
{
const { ref } = useFocusable({ focusKey: 'homepage-link' });
return <a
ref={ref}
className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1 not-mobile:shadow-2xl"
onClick={() =>
{
if (data.homepage) systemApi.api.system.open.post({ url: data.homepage });
}}>
{data.homepage ?? <div className="skeleton h-4 w-54" />}
</a>;
}
function TitleArea (data: {
emulator?: FrontEndEmulatorDetailed;
onInstall: (source: string) => void;
onUpdate: (source: string) => void;
})
{
const globalDialog = useContext(GlobalDialogContext);
const navigation = useNavigate();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
...storeEmulatorDeleteMutation,
onSuccess (data, variables, onMutateResult, context)
{
context.client.refetchQueries(storeEmulatorDetailsQuery(variables));
},
});
const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? ''));
const updateToVersion = data.emulator?.downloads.find(d => d.version === data.emulator!.storeDownloadInfo?.type)?.version ?? data.emulator?.downloads[0]?.version;
const deleteBios = useMutation({
...deleteBiosMutation,
onSuccess (data, variables, onMutateResult, context)
{
context.client.refetchQueries(storeEmulatorDetailsQuery(variables));
toast.success("BIOS Deleted", { icon: <Trash2 /> });
},
});
const installProgressRef = useRef<HTMLProgressElement>(null);
const { data: biosInstallJob, state: biosDownloadState } = useJobStatus('bios-download-job', {
query: { id: data.emulator?.name },
onError (error)
{
console.log(error);
toast.error(getErrorMessage(error) ?? "Error During Bios Download");
},
onProgress (process)
{
if (installProgressRef.current)
installProgressRef.current.value = process;
},
onCompleted (data)
{
toast.success("BIOS Downloaded", { icon: <Download /> });
},
onEnded (data)
{
queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator));
},
});
const { data: installJob, state: installState } = useJobStatus('download-emulator', {
onError (error)
{
console.log(error);
toast.error(getErrorMessage(error) ?? "Error During Download");
},
onProgress (process)
{
if (installProgressRef.current)
installProgressRef.current.value = process;
},
onEnded (data)
{
console.log("Finished Install", data.emulator);
if (data.emulator)
queryClient.refetchQueries(storeEmulatorDetailsQuery(data.emulator));
},
});
const isInstalling = !!installJob || !!biosInstallJob;
const options: DialogEntry[] = [];
const installedFromStore = !!data.emulator?.validSources.find(s => s.type === 'store' && s.exists);
if (data.emulator)
{
if (!isInstalling && !installedFromStore)
{
options.push(...data.emulator.downloads.map(d =>
{
const entry: DialogEntry = {
content: `Install From: ${d.name} (${d.type})`,
type: 'primary',
id: d.name,
action: (ctx) =>
{
data.onInstall(d.name);
ctx.close();
}
};
return entry;
}));
} else if (installedFromStore)
{
options.push({
content: "Delete",
type: 'error',
icon: <Trash2 />,
action (ctx)
{
if (data.emulator) deleteMutation.mutate(data.emulator.name);
ctx.close();
},
id: "delete"
});
if ((!data.emulator.storeDownloadInfo || data.emulator.storeDownloadInfo.hasUpdate))
{
options.push({
content: `Update ${data.emulator.storeDownloadInfo?.type}: ${data.emulator.storeDownloadInfo?.version ?? "Unknown"} > ${updateToVersion}`,
type: 'warning',
icon: <CircleFadingArrowUp />,
action (ctx)
{
const source = data.emulator?.storeDownloadInfo?.type ?? data.emulator?.downloads[0]?.type;
if (source) data.onUpdate(source);
ctx.close();
},
id: 'update'
});
}
if (!data.emulator.bios || data.emulator.bios.length <= 0)
{
options.push({
content: "Download BIOS",
type: "primary",
icon: <Download />,
action (ctx)
{
downloadBios.mutate();
ctx.close();
},
id: "download-bios"
});
} else
{
options.push({
content: "Delete BIOS",
type: "error",
icon: <Trash2 />,
action (ctx)
{
if (!data.emulator) return;
deleteBios.mutate(data.emulator.name);
ctx.close();
},
id: "download-bios"
});
}
}
options.push(...data.emulator.validSources.filter(s => s.exists).map(s => ({
content: `Launch: ${s.type}`, type: 'primary', icon: emulatorStatusIcons[s.type], action (ctx)
{
if (!data.emulator) return;
rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type });
navigation({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } });
}, id: `open-${s.type}`
} satisfies DialogEntry)));
}
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: 'title-area',
preferredChildFocusKey: "install-btn",
trackChildren: true,
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
});
let installButtonContent = <></>;
if (!data.emulator)
{
installButtonContent = <span className="loading loading-spinner loading-lg"></span>;
}
else if (isInstalling)
{
const status: any = {
bios: {
download: "Downloading BIOS"
},
install: {
download: "Downloading",
extract: "Extracting"
}
};
installButtonContent = <><span className="loading loading-spinner loading-lg"></span>{installState ? status.install[installState] : biosDownloadState ? status.bios[biosDownloadState] : undefined}</>;
} else if (data.emulator.validSources.some(s => s.exists))
{
installButtonContent = <><Settings /> Options</>;
} else if (data.emulator.downloads.length > 0)
{
installButtonContent = <><Download />Install</>;
} else
{
installButtonContent = <><TriangleAlert />Unsupported</>;
}
const openOptionsDialog = (focusKey: string) => globalDialog.openContext({ content: <ContextList options={options} /> }, focusKey);
const handleOptionsOpen = () =>
{
if (isInstalling || !data.emulator) return false;
openOptionsDialog('install-btn');
};
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
<FocusContext value={focusKey}>
{data.emulator ? <img className="size-32 rounded-full shadow-lg bg-base-200 ring-7 ring-base-200" src={data.emulator.logo}></img> : <div className="skeleton h-32 w-32" />}
<div className="flex flex-col grow gap-1 sm:portrait:items-center md:items-start">
<h1 className="text-4xl font-semibold text-shadow-md">{data.emulator?.name ?? <div className="skeleton h-10 w-84" />}</h1>
<div className="flex gap-2">
{data.emulator?.systems.map(({ id, name, iconUrl }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!iconUrl && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${iconUrl}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden dark:text-shadow-lg">{name}</p>
</div>;
}) ?? <><div className="skeleton h-4 w-48" /><div className="skeleton h-4 w-32" /></>}
</div>
<div className="flex pt-2 gap-1">
<HomePageLink homepage={data.emulator?.homepage} />
<div className="divider divider-horizontal m-0"></div>
{!!data.emulator?.bios?.[0] && <div className="tooltip" data-tip="Has BIOS">
<div className="flex items-center justify-center bg-base-200 p-2 rounded-full"><Cpu className="size-5" /></div>
</div>}
{data.emulator && data.emulator.integrations.length > 0 && <div className="tooltip" data-tip="Has Integration">
<div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div>
</div>}
{data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) && <div className="tooltip" data-tip="Save Support">
<div className="bg-base-200 rounded-full p-2"><CloudUpload className="size-5" /></div>
</div>}
</div>
</div>
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
<FocusTooltip visible={hasFocusedChild} parentRef={ref} />
{(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion && <div className="tooltip tooltip-warning" data-tip="Update Available">
<Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => openOptionsDialog('update-warning-bt')}><CircleFadingArrowUp /></Button>
</div>}
{(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS">
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => openOptionsDialog('bios-warning-bt')}><TriangleAlert /></Button>
</div>}
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2 light:ring-offset-7 light:ring-offset-base-100 light:focused:ring-offset-0 shadow-lg" onAction={handleOptionsOpen} >
<div className="flex gap-4">
{installButtonContent}
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
<ChevronDown />
</div>
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
</Button>
</div>
</FocusContext >
</div >;
}
function Description (data: { emulator?: FrontEndEmulatorDetailed; })
{
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
<div>{data.emulator?.description ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[40%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-full"></div>
</div>}</div>
</div>;
}
const capabilityIconMap: Record<string, any> = {
saves: <CloudUpload />,
fullscreen: <Fullscreen />,
resolution: <Monitor />,
config: <Settings2 />,
batch: <Terminal />
};
function InfoTabs (data: { tabs: Record<string, FilterOption>, selectTab: (v: string) => void; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'emulator-info-tabs-section', onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start' })(focusKey, ref.current, d) });
return <div ref={ref} className="divider scroll-mt-48">
<FocusContext value={focusKey}>
<FilterUI id="emulator-info-tabs" options={data.tabs} setSelected={v => data.selectTab(v)} />
</FocusContext>
</div>;
}
export function RouteComponent ()
{
const { id } = Route.useParams();
const router = useRouter();
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: `GAME_DETAIL_${id}`,
trackChildren: true,
preferredChildFocusKey: 'title-area'
});
const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id));
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery(id));
const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id));
const [infoTab, setInfoTab] = useState("stats");
useShortcuts(focusKey, () => [{
label: "Return",
action: (e) => HandleGoBack(router, e),
button: GamePadButtonCode.B
}], [router]);
const installMutation = useMutation({
...installEmulatorMutation(id),
onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
});
const stats: StatEntry[] = [];
if (emulator)
{
if (emulator.keywords)
stats.push({ label: "Tags", content: emulator.keywords });
if (emulator.storeDownloadInfo)
stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` });
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
stats.push(...emulator.validSources.flatMap(s => [{
label: "Source", content: <div className="flex flex-col grow">
<div className="flex grow flex-wrap justify-between gap-1">
<div className="flex gap-1">{emulatorStatusIcons[s.type]}{s.type}</div>
<div className="text-base-content/40">{s.binPath}</div>
</div>
{emulator.integrations.some(i => i.source?.type === s.type) && <div className="divider m-0"></div>}
{emulator.integrations.filter(i => i.source?.type === s.type).map(i =>
{
return <div key={i.id} className="flex flex-wrap justify-between gap-1">
<div className="flex gap-2">
<Puzzle />
<div>{i.id}</div>
</div>
<div className="flex flex-wrap text-base-content/40">
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
</div>
</div>;
})}
</div>
}]));
if (emulator.bios)
stats.push({
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
});
}
const infoTabs: Record<string, FilterOption> = {
stats: { label: "Stats", selected: infoTab === 'stats', icon: <Info /> },
};
if (emulator?.storeDownloadInfo?.hasUpdate)
{
infoTabs.update = { label: "Update", icon: <CircleFadingArrowUp />, selected: infoTab === 'update' };
}
return (
<AnimatedBackground ref={ref} className="" scrolling>
<AutoFocus focus={focusSelf} />
<FocusContext.Provider value={focusKey}>
<StickyHeaderUI ref={ref} />
<div className="flex flex-col z-10">
<div className="w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} onInstall={s => installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
<div className='mobile:hidden left-0 top-0 absolute bg-gradient'></div>
<div className='mobile:hidden left-0 top-0 absolute bg-noise'></div>
<div className='mobile:hidden left-0 top-0 absolute bg-dots'></div>
</div>
<div className="flex flex-col bg-base-100 gap-4 pt-4 h-[50vh] min-h-128 grow text-lg">
{isEmulatorPending || (!!emulator && emulator?.screenshots.length > 0) && <Screenshots className="grow bg-base-200" screenshots={emulator?.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />}
<Description emulator={emulator} />
</div>
</div>
<div className="flex flex-col bg-base-100 py-4 gap-12 z-10">
<InfoTabs tabs={infoTabs} selectTab={setInfoTab} />
{infoTab === 'stats' && <StatList id="emulator-details-stats" stats={stats} focusable={false} />}
{infoTab === 'update' && <Markdown>{emulator?.storeDownloadInfo?.description}</Markdown>}
{recommendedEmulators && <div className="relative bg-base-200">
<div className="bg-dots z-0"></div>
<EmulatorsSection
id={`${id}-recommended`}
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<h2 className="font-bold uppercase tracking-widest">
More Emulators
</h2></>}
onFocus={scrollIntoViewHandler({ block: 'center' })}
onSelect={(em, focus) =>
{
if (em.source === 'local') return;
router.navigate({
to: '/store/details/emulator/$id', params: { id: em.name }
});
}}
emulators={recommendedEmulators} />
</div>}
{recommendedGames && recommendedGames.length > 0 && <div className="px-6 py-3">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Related Games
</h2>
</div>
<GamesSection showSources={true} onFocus={scrollIntoViewHandler({ behavior: 'smooth', block: 'center' })} onSelect={(id) =>
{
router.navigate({
to: '/game/$source/$id', params: { id: id.id, source: id.source }
});
}} games={recommendedGames} /></div>}
</div>
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
<FloatingShortcuts />
</div>
</FocusContext.Provider>
</AnimatedBackground >
);
}