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

@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { SystemInfoContext } from "../scripts/contexts";
import { AppContext, SystemInfoContext } from "../scripts/contexts";
import { systemApi } from "../scripts/clientApi";
import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared';
import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared';
import LoadingScreen from "./LoadingScreen";
import { GamepadKeyboard } from "./GamepadKeyboard";
export default function AppCommunication (data: { children: any; })
{
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
const [appContext, setAppContext] = useState<AppInfoContext>({} as AppInfoContext);
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
@ -25,6 +26,9 @@ export default function AppCommunication (data: { children: any; })
case "focus":
window.focus();
break;
case "activeTask":
setAppContext(c => ({ ...c, activeTaskProgress: data.progress }));
break;
case "loading":
setLoadingInfo(data.state);
if (loadingProgressBarRef.current)
@ -45,17 +49,19 @@ export default function AppCommunication (data: { children: any; })
}, []);
return <SystemInfoContext value={systemInfo}>
{loading ?
<LoadingScreen>
<div className="flex flex-col items-center gap-4">
<div className="flex gap-2">
<span className="loading loading-spinner loading-xl"></span>
{loadingInfo}
<AppContext value={appContext}>
{loading ?
<LoadingScreen>
<div className="flex flex-col items-center gap-4">
<div className="flex gap-2">
<span className="loading loading-spinner loading-xl"></span>
{loadingInfo}
</div>
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
</div>
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
</div>
</LoadingScreen>
: data.children}
<GamepadKeyboard />
</LoadingScreen>
: data.children}
<GamepadKeyboard />
</AppContext>
</SystemInfoContext>;
}

View file

@ -6,7 +6,7 @@ import
import CardElement, { GameCardParams } from "./CardElement";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
export interface GameMetaExtra extends GameMeta
@ -16,7 +16,7 @@ export interface GameMetaExtra extends GameMeta
focusKey: string;
}
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams)
{
let preview: GameCardParams['preview'] = data.game.preview;
if (!preview && data.game.previewUrls)
@ -31,7 +31,28 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
const handleAltAction = (ctx: InteractParamsArgs) =>
{
data.game.onQuickAction?.();
data.onQuickAction?.({ event, focusKey: data.game.focusKey });
oneShot('click');
};
useShortcuts(data.game.focusKey, () =>
{
const options: Shortcut[] = [{
label: "Details",
button: GamePadButtonCode.A,
action: event => handleAction({ event, focusKey: data.game.focusKey })
}];
if (data.onQuickAction || data.game.onQuickAction)
{
options.push({ label: "Play", button: GamePadButtonCode.X, action: event => handleAltAction({ event, focusKey: data.game.focusKey }) });
}
return options;
}, [data.onQuickAction, data.game.onQuickAction, data.game.focusKey]);
return (
<CardElement
@ -91,7 +112,12 @@ export function CardList (data: {
>
<FocusContext.Provider value={focusKey}>
{data.games.map((g, i) => <LocalCardElement
key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
key={g.id}
onFocus={data.onFocus}
game={g}
onAction={() => data.onSelectGame?.(g.id)}
i={i}
/>)}
{data.finalElement}
</FocusContext.Provider>
</ul>

View file

@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}>
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable not-active:control-mouse:hover:bg-base-300 in-focused:z-100",
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable light:not-in-data-[selected=true]:control-mouse:hover:bg-base-100 dark:not-in-data-[selected=true]:control-mouse:hover:bg-base-300 in-focused:z-100",
data.className,
colors[data.type],
"in-focused:bg-base-content in-focused:text-base-100")}>
@ -166,7 +166,7 @@ export function ContextDialog (data: {
}] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
twMerge("fixed modal cursor-pointer bg-base-300/60 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }), data.backdropClassName)
}
onClick={handleClose}>
@ -174,7 +174,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
<div
className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl not-mobile:drop-shadow-2xl",
data.open ? "animate-scale-delayed" : "opacity-0",
data.className)
}

View file

@ -116,7 +116,7 @@ export function FilterUI (data: {
style={{ viewTransitionName: `filter-${data.id}` }}
>
<FocusContext.Provider value={focusKey}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full gap-0.5 p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>}

View file

@ -2,13 +2,15 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { HardDrive } from "lucide-react";
import { JSX, useContext } from "react";
import { useLocalSetting } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import { allGamesQuery } from "@queries/romm";
import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { FOCUS_KEYS } from "../scripts/types";
export interface GameListParams extends FocusParams
{
@ -17,6 +19,7 @@ export interface GameListParams extends FocusParams
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
focus?: string;
className?: string;
finalElement?: JSX.Element | JSX.Element[];
@ -97,7 +100,7 @@ export function GameList (data: GameListParams)
const previewUrls = g.path_covers.map(c =>
{
const url = c.startsWith("http") ? new URL(c) : 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;
});
@ -105,13 +108,13 @@ export function GameList (data: GameListParams)
let platformUrl: URL | undefined = undefined;
if (g.path_platform_cover)
{
platformUrl = g.path_platform_cover.startsWith("http") ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl = isUrl(g.path_platform_cover) ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
}
return {
id: `${g.id.source}@${g.id.id}`,
focusKey: `${data.id}-${g.id.source}@${g.id.id}`,
focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id),
title: g.name ?? "",
subtitle: (
<div className="flex gap-1 items-center">
@ -122,6 +125,7 @@ export function GameList (data: GameListParams)
previewUrls: previewUrls,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
onQuickAction: data.onQuickAction ? () => data.onQuickAction?.(g.id, g.source, g.source_id) : undefined,
onFocus: () => handleFocus(g.id, g.source, g.source_id)
} satisfies GameMetaExtra;
},

View file

@ -387,10 +387,6 @@ export function GamepadKeyboard ()
const magnitudeSqr = (x * x) + (y * y);
const magnitude = Math.sqrt(magnitudeSqr);
const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex];
//const lerpX = (element?.left ?? 0);
//const lerpY = (element?.top ?? 0);
const size = 12;
circle.style.left = `calc(50% + ${50 * x}% - 16px)`;
circle.style.top = `calc(50% + ${50 * y}% - 16px)`;
circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`;

View file

@ -0,0 +1,28 @@
import { useState } from "react";
import { GlobalDialogContext } from "../scripts/contexts";
import { useContextDialog } from "./ContextDialog";
export default function GlobalContextDialog (data: { children: any; })
{
const [currentContext, setCurrentContext] = useState<any | undefined>(undefined);
const [preferredChildFocusKey, setPreferredChildFocusKey] = useState<string | undefined>(undefined);
const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | undefined>(undefined);
const { dialog, setOpen } = useContextDialog('global-context-dialog', {
content: currentContext,
onClose: onCloseCallback,
preferredChildFocusKey: preferredChildFocusKey
});
return <GlobalDialogContext value={{
openContext (context, focusKey)
{
setCurrentContext(context.content);
setPreferredChildFocusKey(context.preferredChildFocusKey);
setOnCloseCallback(context.onClose);
setOpen(true, focusKey);
},
}}>
{data.children}
{dialog}
</GlobalDialogContext>;
}

View file

@ -29,10 +29,11 @@ import { twMerge } from "tailwind-merge";
import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { SystemInfoContext } from "../scripts/contexts";
import { AppContext, SystemInfoContext } from "../scripts/contexts";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
function HeaderAvatar (data: {
id: string;
@ -73,6 +74,7 @@ export interface HeaderButton
external?: boolean;
action?: () => void;
className?: string;
shortcutLabel?: string;
}
export interface HeaderAccount
@ -111,14 +113,22 @@ function NotificationStatus ()
function ClockStatus ()
{
const ref = useRef<HTMLSpanElement>(null);
const navigate = useNavigate();
const app = useContext(AppContext);
const refClock = useRef<HTMLSpanElement>(null);
const activeTaskProgress = app.activeTaskProgress;
const handleTaskClick = () =>
{
navigate({ to: '/settings/tasks' });
};
const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick });
useEffect(() =>
{
function update ()
{
if (ref.current)
if (refClock.current)
{
ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
@ -142,7 +152,16 @@ function ClockStatus ()
return () => clearTimeout(timeout);
}, []);
return <div className="flex gap-3 sm:text-xs md:text-2xl items-center"><span ref={ref}></span><Clock className="sm:size-4 md:size-8" /></div>;
useShortcuts(focusKey, () => [{
label: "Downloads", button: GamePadButtonCode.A, action (e)
{
handleTaskClick();
},
}]);
return <div ref={ref} className="flex gap-3 sm:text-xs md:text-2xl items-center">
<span ref={refClock}></span>
{activeTaskProgress ? <div onClick={handleTaskClick} className={twMerge("radial-progress bg-primary text-primary-content border-primary border-4 in-focused:ring-7 in-focused:ring-primary in-focused:bg-base-content in-focused:text-base-200 in-focused:border-base-content", activeTaskProgress ? "cursor-pointer" : "")} style={{ "--value": activeTaskProgress, "--size": "2rem", "--thickness": "0.3rem" }} role="progressbar"></div> : <Clock className="sm:size-4 md:size-8" />}</div>;
}
function BluetoothStatus ()
@ -288,6 +307,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
{data.buttonElements}
{data.buttons?.map(b => <RoundButton
key={b.id}
shortcutLabel={b.shortcutLabel}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id}
external={b.external}
@ -327,7 +347,19 @@ export function HeaderUI (data: HeaderUIParams)
<FocusContext value={focusKey}>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title}
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", 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,
shortcutLabel: "Settings"
}
]} />
</FocusContext>
</header >

View file

@ -96,10 +96,10 @@ export default function HeaderSearchField (data: {
isFocusBoundary: data.compact && showInput
});
return <div ref={ref} className='flex items-center'>
return <div ref={ref} className='flex items-center' style={{ viewTransitionName: 'header-search' }}>
<FocusContext value={focusKey}>
{(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
{data.compact && !showInput && <RoundButton cssStyle={{ viewTransitionName: 'search-button' }} onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
</FocusContext>
</div>;
}

View file

@ -9,10 +9,11 @@ export function RoundButton (data: {
external?: boolean;
style?: ButtonStyle;
cssStyle?: CSSProperties;
shortcutLabel?: string;
} & InteractParams & FocusParams)
{
return (
<Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
<Button shortcutLabel={data.shortcutLabel} cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
{data.children}
</Button>

View file

@ -8,6 +8,7 @@ import Carousel from "./Carousel";
import { ContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { twMerge } from "tailwind-merge";
import { isUrl } from "@/shared/utils";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
{
@ -21,8 +22,9 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
}
}); 4096;
const url = isUrl(data.path) ? data.path : `${RPC_URL(__HOST__)}${data.path}`;
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={url} loading="lazy" decoding="async" />
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div>
</div>;
}
@ -59,8 +61,9 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s
}
}
], [data.preview, focusKey, data.screenshots?.length ?? 0]);
const url = isUrl(data.screenshots?.[data.preview]) ? data.screenshots?.[data.preview] : `${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`;
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={url} loading="lazy" />;
}
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)

View file

@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types";
@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
const options: DialogEntry[] = [
{
content: "Home",
icon: <Gamepad2 />,
icon: <Home />,
action (ctx)
{
setOpen(false);

View file

@ -1,25 +1,25 @@
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { DownloadsLookupFilter, DownloadsLookupFilterValues, GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { RoundButton } from "./RoundButton";
import classNames from "classnames";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation";
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react";
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { sourceIconMap } from "./Constants";
import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog";
import { ContextList, DialogEntry } from "./ContextDialog";
import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared";
import { useContext } from 'react';
import { GlobalDialogContext } from '../scripts/contexts';
function FilterButton (data: {
id: string,
filters?: GameListFilterType,
tooltip: string,
icon: any;
dialog: {
setToggle: (focNewSourceFocusKey?: string | undefined) => void;
};
dialog: (focNewSourceFocusKey: string) => void;
isActive: boolean;
})
{
const handleAction = () => data.dialog.setToggle(data.id);
const handleAction = () => data.dialog(data.id);
useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]);
return <div className="tooltip tooltip-right" data-tip={data.tooltip}>
<RoundButton
@ -32,6 +32,89 @@ function FilterButton (data: {
</div>;
}
export function SideDownloadFilters (data: {
id: string,
filters?: DownloadsLookupFilter;
setLocalFilter: (filter: DownloadsLookupFilter) => void,
localFilter: DownloadsLookupFilter,
filterValues: DownloadsLookupFilterValues | undefined;
})
{
const { ref, focusKey } = useFocusable({ focusKey: data.id });
const globalDialog = useContext(GlobalDialogContext);
const orderByDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={data.filterValues?.orderBy
.map(o => ({
content: o,
selected: data.localFilter.orderBy === o,
id: `sort-by-${o}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, orderBy: o });
ctx.close();
},
}))} />,
preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
}, focusKey);
const orderDirectionDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={
[{ label: 'asc', icon: <ArrowDown /> }, { label: 'desc', icon: <ArrowUp /> }]
.map(o => ({
content: o.label,
selected: data.localFilter.sortDirection === o.label,
icon: o.icon,
id: `sort-direction-${o.label}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, sortDirection: o.label as any });
ctx.close();
},
}))
} />,
preferredChildFocusKey: `sort-direction-${data.localFilter.orderBy}`
}, focusKey);
const sourceFilterDialog = (focusKey: string) => globalDialog.openContext({
content: <ContextList options={data.filterValues?.source
.map<DialogEntry>(o => ({
content: o,
icon: sourceIconMap[o],
selected: data.localFilter.source === o,
id: `source-filter-${o}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, source: o });
ctx.close();
},
}))} />,
preferredChildFocusKey: `source-filter-${data.localFilter.source}`
}, focusKey);
return <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Sorting Direction' id='filter-order-direction' dialog={orderDirectionDialog} isActive={!!data.localFilter.sortDirection} icon={<ArrowUpDown />} />
{!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source} icon={<Store />} />
}
{Object.values(data.localFilter).some(v => v !== undefined) &&
<>
<div className="divider m-0"></div>
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</>
}
</FocusContext>
</div>;
}
export default function SideFilters (data: {
id: string,
filters?: GameListFilterType;
@ -42,96 +125,107 @@ export default function SideFilters (data: {
{
const { ref, focusKey } = useFocusable({ focusKey: data.id });
const globalDialog = useContext(GlobalDialogContext);
const orderByDialog = useContextDialog('order-by-dialog', {
content: <ContextList options={([
{ stat: "name", icon: <ArrowDownAz /> },
{ stat: "activity", icon: <ClockArrowDown /> },
{ stat: "added", icon: <CalendarArrowDown /> },
{ stat: "release", icon: <Rocket /> },
] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
.map(o => ({
content: o.stat,
icon: o.icon,
selected: data.localFilter.orderBy === o.stat,
id: `sort-by-${o.stat}`,
const openSourceDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={["romm"]
.map<DialogEntry>(o => ({
content: o,
icon: sourceIconMap[o],
selected: data.localFilter.source === o,
id: `source-filter-${o}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, source: o });
ctx.close();
},
})).concat({
content: "Local Only",
icon: <HardDrive />,
selected: data.localFilter.localOnly === true,
id: `source-filter-local`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined });
else data.setLocalFilter({ ...data.localFilter, localOnly: true });
ctx.close();
},
})} />, preferredChildFocusKey: `source-filter-${data.localFilter.source}`
}, focusKey);
};
const openGenreDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={data.filterValues?.genres.map(g => ({
content: g,
selected: data.localFilter.genres?.includes(g),
id: `genre-filter-${g}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] });
else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] });
ctx.close();
},
}))} />,
preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
});
}))} />
}, focusKey);
};
const sourceFilterDialog = useContextDialog('source-filter-dialog', {
content: <ContextList options={["romm"]
.map<DialogEntry>(o => ({
content: o,
icon: sourceIconMap[o],
selected: data.localFilter.source === o,
id: `source-filter-${o}`,
const openSortingDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={([
{ stat: "name", icon: <ArrowDownAz /> },
{ stat: "activity", icon: <ClockArrowDown /> },
{ stat: "added", icon: <CalendarArrowDown /> },
{ stat: "release", icon: <Rocket /> },
] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
.map(o => ({
content: o.stat,
icon: o.icon,
selected: data.localFilter.orderBy === o.stat,
id: `sort-by-${o.stat}`,
type: 'primary',
action (ctx)
{
data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
ctx.close();
},
}))} />, preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
}, focusKey);
};
const openAgeRatingDialog = (focusKey: string) =>
{
globalDialog.openContext({
content: <ContextList options={data.filterValues?.age_ratings.map(a => ({
content: a,
selected: data.localFilter.age_ratings?.includes(a),
id: `age-rating-filter-${a}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, source: o });
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] });
else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] });
ctx.close();
},
})).concat({
content: "Local Only",
icon: <HardDrive />,
selected: data.localFilter.localOnly === true,
id: `source-filter-local`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined });
else data.setLocalFilter({ ...data.localFilter, localOnly: true });
ctx.close();
},
})} />,
preferredChildFocusKey: `source-filter-${data.localFilter.source}`
});
const genreFilterDialog = useContextDialog('genre-filter-dialog', {
content: <ContextList options={data.filterValues?.genres.map(g => ({
content: g,
selected: data.localFilter.genres?.includes(g),
id: `genre-filter-${g}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] });
else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] });
ctx.close();
},
}))} />
});
const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', {
content: <ContextList options={data.filterValues?.age_ratings.map(a => ({
content: a,
selected: data.localFilter.age_ratings?.includes(a),
id: `age-rating-filter-${a}`,
type: 'primary',
action (ctx)
{
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] });
else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] });
ctx.close();
},
}))} />
});
}))} />
}, focusKey);
};
return <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={ageRatingFilterDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
<FilterButton tooltip='Genre' id='filter-genre' dialog={genreFilterDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={openSortingDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={openAgeRatingDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
<FilterButton tooltip='Genre' id='filter-genre' dialog={openGenreDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
{!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
<FilterButton tooltip='Source' id='filter-source' dialog={openSourceDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
}
{Object.values(data.localFilter).some(v => v !== undefined) &&
<>
@ -139,10 +233,6 @@ export default function SideFilters (data: {
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</>
}
{orderByDialog.dialog}
{sourceFilterDialog.dialog}
{genreFilterDialog.dialog}
{ageRatingFilterDialog.dialog}
</FocusContext>
</div>;
}

View file

@ -30,7 +30,11 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
</ActionButton>;
}
export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
export default function ActionButtons (data: {
game?: FrontEndGameTypeDetailed,
source: string,
id: string;
})
{
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
const navigate = useNavigate();

View file

@ -1,21 +1,20 @@
import { rommApi } from "@/mainview/scripts/clientApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { JSX, useEffect, useRef, useState } from "react";
import { JSX, useContext, useEffect, useRef, useState } from "react";
import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { ContextList, DialogEntry } from "../ContextDialog";
import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton";
import { useRouter } from "@tanstack/react-router";
import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
export function usePlayMutation (navigate: UseNavigateResult<string>)
{
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const playMut = useMutation({
...playMutation, onError (error)
{
@ -23,9 +22,36 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
},
onSuccess (data, { source, id }, onMutateResult, context)
{
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
},
});
return playMut;
}
export function playGame (source: string, id: string, cmd: CommandEntry, navigate: UseNavigateResult<string>, playMutation: (options: { source: string, id: string, command_id: string | number; }) => void)
{
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
navigate({ to: '/embedded/$source/$id', params: { source: source, id: id }, search: Object.fromEntries(params.entries()) });
} else
{
playMutation({ source: source, id: id, command_id: cmd.id });
}
}
export default function MainActions (data: {
game?: FrontEndGameTypeDetailed,
source: string,
id: string;
})
{
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const navigate = useNavigate();
const globalDialog = useContext(GlobalDialogContext);
const ws = useRef<{ send: (data: string) => void; }>(undefined);
const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
@ -42,7 +68,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (preferredCommand && c.id !== preferredCommand) return false;
return true;
});
const playMut = usePlayMutation(navigate);
useEffect(() =>
{
const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe();
@ -99,32 +125,33 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
}
const showProgress = progress !== null && !!progressIcon;
useEffect(() =>
{
if (showProgress) return;
showInstallOptions(false);
}, [showProgress]);
const handlePlay = (cmd?: CommandEntry) =>
{
if (!cmd) return;
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
} else
{
playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id });
}
};
let mainButton: any | undefined = undefined;
let showAllCommandsAction: ((focusKey: string) => void) | undefined;
let mainAction: () => void;
if (status === 'installed')
{
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
mainAction = () => handlePlay(validDefaultCommand);
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({
content: <ContextList options={validCommands.map((c, i) =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx)
{
setPreferredCommand(c.id);
playGame(data.source, data.id, c, navigate, playMut.mutate);
},
};
return commands;
})} />,
preferredChildFocusKey: String(preferredCommand)
}, focusKey);
mainAction = () => validDefaultCommand ? playGame(data.source, data.id, validDefaultCommand, navigate, playMut.mutate) : undefined;
mainButton = <div className="flex gap-2">
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
key="primary"
@ -182,7 +209,18 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
case 'install':
if (installSources && installSources.length > 1)
{
showInstallSource(true, 'mainAction');
globalDialog.openContext({
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
}, 'mainAction');
} else
{
installMut.mutate({});
@ -222,55 +260,21 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
return shortcuts;
}, [showAllCommandsAction, mainAction]);
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map((c, i) =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx)
{
setPreferredCommand(c.id);
handlePlay(c);
},
};
return commands;
})} />,
preferredChildFocusKey: String(preferredCommand)
});
const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', {
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
});
const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', {
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
});
return <div className="flex gap-2">
{mainButton}
<div className="divider divider-horizontal m-0"></div>
{showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
{showProgress && <ActionButton onAction={() => globalDialog.openContext({
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
}, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
<div className="flex flex-row">
{progressIcon}
@ -278,8 +282,5 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div>
</ActionButton>}
{installSourcesDialog}
{installOptionsDialog}
{allCommandDialog}
</div>;
}

View file

@ -12,7 +12,7 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
const styles = {
base: 'dark:bg-base-200 light:bg-base-300 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content',
base: 'dark:bg-base-200 light:bg-base-100 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content',
accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent",
primary: "bg-primary text-primary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-primary",
secondary: "bg-secondary text-secondary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary",
@ -22,6 +22,17 @@ const styles = {
error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error",
};
const externalStyles = {
base: '',
accent: "focusable-accent",
primary: "focusable-primary",
secondary: "focusable-secondary",
info: "focusable-info",
success: "focusable-success",
warning: "focusable-warning",
error: "focusable-error",
};
export function Button (data: {
id: string,
children?: any,
@ -64,9 +75,9 @@ export function Button (data: {
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4",
styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined,
data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '',
classNames({
"btn-accent": focused,
"focusable focusable-primary focusable-hover": data.external
"btn-accent": focused
}, data.className))}
type={data.type ?? 'button'}
>

View file

@ -6,11 +6,15 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX } from "react";
import { JSX, useContext } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { useQuery } from "@tanstack/react-query";
import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store";
import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared";
import { rommApi } from "@/mainview/scripts/clientApi";
import { useNavigate } from "@tanstack/react-router";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
import { ContextList, DialogEntry } from "../ContextDialog";
export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />,
@ -28,6 +32,7 @@ export function StoreEmulatorCard (data: {
className?: string;
})
{
const navigate = useNavigate();
const handleSelect = () =>
{
data.onSelect?.(data.emulator.name, focusKey);
@ -45,7 +50,32 @@ export function StoreEmulatorCard (data: {
const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name));
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const globalDialogContext = useContext(GlobalDialogContext);
useShortcuts(focusKey, () => [{
button: GamePadButtonCode.A,
label: "Details",
action: handleSelect
}, {
button: GamePadButtonCode.Y,
label: "Launch Emulator",
action: e =>
{
const entries: DialogEntry[] = 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 });
ctx.close();
navigate({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } });
}, id: `open-${s.type}`
} satisfies DialogEntry));
globalDialogContext.openContext({ content: <ContextList options={entries} /> }, focusKey);
}
}], [handleSelect]);
return (
<div