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:
parent
9a3e605625
commit
9141fb35d4
70 changed files with 1922 additions and 560 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
28
src/mainview/components/GlobalContextDialog.tsx
Normal file
28
src/mainview/components/GlobalContextDialog.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 >
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue