feat: Implemented emulator launching

Fixes #1
This commit is contained in:
Simeon Radivoev 2026-04-04 03:13:09 +03:00
parent 04d5856f7d
commit 09b8b9c6f8
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
20 changed files with 351 additions and 231 deletions

View file

@ -46,7 +46,8 @@ function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const router = useRouter();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]);
const { shortcuts } = useShortcutContext();
return <AnimatedBackground ref={ref} backgroundKey="game-details">

View file

@ -1,13 +1,10 @@
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router';
import DotsLoading from '../components/backgrounds/dots';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts from '../components/Shortcuts';
import { gameQuery } from '@queries/romm';
import { rommApi } from '../scripts/clientApi';
import { useJobStatus } from '../scripts/utils';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
@ -18,34 +15,33 @@ function RouteComponent ()
const router = useRouter();
function HandleGoBack ()
{
router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
if (router.history.canGoBack())
{
router.history.back();
} else
{
router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
}
}
const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
const { data } = useQuery(gameQuery(source, id));
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
if (!data) return;
const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe();
sub.subscribe((e) =>
const { data } = useJobStatus('launch-game', {
onEnded (data)
{
if (e.data.status !== 'playing')
{
HandleGoBack();
}
});
return () =>
HandleGoBack();
},
onWaiting ()
{
sub.close();
};
}, [data?.id]);
HandleGoBack();
},
});
useBlocker({ shouldBlockFn: () => !!data });
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>

View file

@ -116,7 +116,7 @@ function SettingsMenu (data: {})
const { ref, focusKey } = useFocusable({
focusable: true,
focusKey: 'settings-menu',
preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '')
preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}`
});
return <ul

View file

@ -4,11 +4,11 @@ import
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { systemApi } from "@/mainview/scripts/clientApi";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
@ -62,6 +62,7 @@ function TitleArea (data: {
onUpdate: (source: string) => void;
})
{
const navigation = useNavigate();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
...storeEmulatorDeleteMutation,
@ -202,6 +203,15 @@ function TitleArea (data: {
});
}
}
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({

View file

@ -3,7 +3,7 @@ import { RefObject, useEffect, useRef, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc";
import { AnyRouter, Router, useRouter } from "@tanstack/react-router";
import { AnyRouter, useRouter } from "@tanstack/react-router";
import { soundMap } from "./audio/audio";
export type ScrollSaveParams = {
@ -267,6 +267,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
init?: {
query?: Record<string, any>,
onProgress?: (process: number, data: ExtractField<JobResponse<JOB>, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void,
onWaiting?: () => void,
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void;
@ -306,6 +307,11 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
setData(undefined);
init?.onCompleted?.(data.data as any);
break;
case 'waiting':
setState(undefined);
setData(undefined);
init?.onWaiting?.();
break;
default:
setData(data.data as DataPayload);
setState(data.state);