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
This commit is contained in:
Simeon Radivoev 2026-05-15 13:50:55 +03:00
parent 9a3e605625
commit 9141fb35d4
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
70 changed files with 1922 additions and 560 deletions

View file

@ -0,0 +1,129 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import DotsLoading from '@/mainview/components/backgrounds/dots';
import { ContextList, DialogEntry } from '@/mainview/components/ContextDialog';
import { StickyHeaderUI } from '@/mainview/components/Header';
import { Button } from '@/mainview/components/options/Button';
import Screenshots from '@/mainview/components/Screenshots';
import SelectMenu from '@/mainview/components/SelectMenu';
import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import { GlobalDialogContext } from '@/mainview/scripts/contexts';
import { downloadLookupQuery } 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 { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
import { Download } from 'lucide-react';
import prettyBytes from 'pretty-bytes';
import { useContext } from 'react';
export const Route = createFileRoute('/store/details/download/$source/$id')({
component: RouteComponent,
pendingComponent: Loading,
async loader (ctx)
{
const data = await ctx.context.queryClient.fetchQuery(downloadLookupQuery(decodeURIComponent(ctx.params.source), decodeURIComponent(ctx.params.id)));
return { data };
}
});
function Loading ()
{
const { ref, focusSelf } = useFocusable({ focusKey: 'download-details' });
return <>
<DotsLoading ref={ref} />
<AutoFocus focus={focusSelf} />
</>;
}
const imagesMap = new Set(['JPEG', 'PNG', 'Motion JPEG', 'Item Image']);
const videoFormat = new Set(['h.264']);
const downloadsBlacklist = new Set(['JPEG Thumb', 'Metadata', 'Thumbnail', 'Item Tile', 'Archive BitTorrent', ...videoFormat, ...imagesMap]);
function Details (data: { onDownload: (focusKey: string) => void; })
{
const { data: download } = Route.useLoaderData();
const screenshots = download.files.filter(f => f.format && imagesMap.has(f.format)).map(f => f.download_url);
if (screenshots.length <= 0 && download.cover_url) screenshots.push(download.cover_url);
return <div className='flex flex-col'>
<Screenshots className='bg-base-300 h-64' screenshots={screenshots} />
<div className='flex flex-col gap-4'>
<div className='flex bg-base-200 p-16 justify-between'>
<div className=' flex gap-2'>
{!!download.cover_url && <img className='w-32 object-cover rounded-2xl' src={download.cover_url} />}
<div className='flex flex-col grow'>
<div className='font-bold text-2xl'>{download.name}</div>
<div className='flex gap-1'>
<div>{download.date?.toDateString()}</div>
<div className="divider divider-horizontal m-0"></div>
<div>{download.source}</div>
</div>
</div>
</div>
<div className='flex items-center'>
<Button external id='download-btn' className='gap-2 font-semibold text-2xl' style='accent' onAction={(ctx) => data.onDownload(ctx.focusKey!)} ><Download />Download</Button>
</div>
</div>
<div className='flex gap-4 px-16 py-4 justify-center'>
{!!download.summary && <div className='flex prose grow'>
<div dangerouslySetInnerHTML={{ __html: download.summary }}></div>
</div>}
<div>
<div className="divider"><Download size={64} /> Downloads</div>
<ul className='flex flex-col gap-2'>
{download.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f => <li className='flex bg-base-300 gap-2 px-4 py-2 rounded-2xl justify-between'>
{f.id}
<span className='font-semibold'>{!!f.size && prettyBytes(f.size)}</span>
</li>)}
</ul>
</div>
</div>
</div>
</div>;
}
function RouteComponent ()
{
const navigate = useNavigate();
const router = useRouter();
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'download-details', preferredChildFocusKey: 'download-btn' });
const { data } = Route.useLoaderData();
const globalDialog = useContext(GlobalDialogContext);
useShortcuts(focusKey, () => [{
label: "Return",
action: (e) => HandleGoBack(router, e),
button: GamePadButtonCode.B
}], [router]);
return <div ref={ref} className='absolute w-full h-full overflow-y-scroll overflow-x-hidden'>
<FocusContext value={focusKey}>
<StickyHeaderUI ref={ref} />
<Details onDownload={(focusKey) => globalDialog.openContext({
content: <ContextList options={data.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f =>
{
const option: DialogEntry = {
id: f.id,
content: f.id,
type: 'primary',
action (ctx)
{
navigate({
to: '/game/add', search: {
gameLocation: f.download_url,
search: data.name,
step: 1
}
});
},
};
return option;
})} />
}, focusKey)} />
<FloatingShortcuts />
</FocusContext>
<SelectMenu rootFocusKey={focusKey} />
<AutoFocus focus={focusSelf} />
</div>;
}