feat: implemented haptics

feat: Implemented a select menu
fix: Only used audio clips compile
This commit is contained in:
Simeon Radivoev 2026-04-07 15:28:56 +03:00
parent 02a4f2c9a9
commit 54dd9256e3
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
51 changed files with 580 additions and 466 deletions

View file

@ -1,7 +1,7 @@
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { Router } from ".";
import { useEffect } from "react";
import audioCallbacks from "./scripts/audio/audioCallbacks";
import audioCallbacks from "./scripts/feedbackCallbacks";
import { client as rommClient } from "../clients/romm/client.gen";
import { RPC_URL } from "@/shared/constants";

BIN
src/mainview/assets/intro.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,304 +1,64 @@
{
"sprite": {
"Classic UI SFX - Chords #1": [
"Classic UI SFX - Chords #2": [
0,
4005.215419501134
],
"Classic UI SFX - Chords #10": [
6000,
4005.215419501134
],
"Classic UI SFX - Chords #11": [
12000,
4005.215419501134
],
"Classic UI SFX - Chords #12": [
18000,
4005.215419501134
],
"Classic UI SFX - Chords #13": [
24000,
4005.215419501134
],
"Classic UI SFX - Chords #14": [
30000,
4005.215419501134
],
"Classic UI SFX - Chords #15": [
36000,
4005.215419501134
],
"Classic UI SFX - Chords #16": [
42000,
4005.215419501134
],
"Classic UI SFX - Chords #17": [
48000,
4005.215419501134
],
"Classic UI SFX - Chords #18": [
54000,
4005.215419501134
],
"Classic UI SFX - Chords #19": [
60000,
4005.215419501127
],
"Classic UI SFX - Chords #2": [
66000,
4005.215419501127
],
"Classic UI SFX - Chords #20": [
72000,
4005.215419501127
],
"Classic UI SFX - Chords #3": [
78000,
4005.215419501127
],
"Classic UI SFX - Chords #4": [
84000,
4005.215419501127
],
"Classic UI SFX - Chords #5": [
90000,
4005.215419501127
],
"Classic UI SFX - Chords #6": [
96000,
4005.215419501127
],
"Classic UI SFX - Chords #7": [
102000,
4005.215419501127
],
"Classic UI SFX - Chords #8": [
108000,
4005.215419501127
],
"Classic UI SFX - Chords #9": [
114000,
4005.215419501127
],
"Classic UI SFX - Short - High #1": [
120000,
2546.893424036284
],
"Classic UI SFX - Short - High #10": [
124000,
2552.0861678004535
],
"Classic UI SFX - Short - High #11": [
128000,
2927.0975056689394
],
"Classic UI SFX - Short - High #12": [
132000,
2927.0975056689394
],
"Classic UI SFX - Short - High #13": [
136000,
3000
],
"Classic UI SFX - Short - High #14": [
140000,
2802.0861678004394
],
"Classic UI SFX - Short - High #15": [
144000,
2723.9455782312803
],
"Classic UI SFX - Short - High #16": [
148000,
2927.0975056689394
],
"Classic UI SFX - Short - High #17": [
152000,
2880.226757369627
],
"Classic UI SFX - Short - High #18": [
156000,
2359.387755102034
],
"Classic UI SFX - Short - High #19": [
160000,
3052.0861678004394
],
"Classic UI SFX - Short - High #2": [
165000,
2843.7641723355964
],
"Classic UI SFX - Short - High #20": [
169000,
2015.6462585034092
],
"Classic UI SFX - Short - High #21": [
173000,
2005.215419501127
],
"Classic UI SFX - Short - High #22": [
177000,
2489.5918367346894
],
"Classic UI SFX - Short - High #23": [
181000,
2458.3446712018144
],
"Classic UI SFX - Short - High #24": [
185000,
2093.7641723355964
],
"Classic UI SFX - Short - High #25": [
189000,
2005.215419501127
],
"Classic UI SFX - Short - High #3": [
193000,
2864.6031746031613
],
"Classic UI SFX - Short - High #4": [
197000,
3031.2698412698464
],
"Classic UI SFX - Short - High #5": [
202000,
2598.9795918367236
],
"Classic UI SFX - Short - High #6": [
206000,
2427.0975056689394
],
"Classic UI SFX - Short - High #7": [
210000,
2468.752834467125
],
"Classic UI SFX - Short - High #8": [
214000,
2916.666666666657
],
"Classic UI SFX - Short - High #9": [
218000,
2250
],
"Classic UI SFX - Short - Low #1": [
222000,
2010.4308390022538
],
"Classic UI SFX - Short - Low #10": [
226000,
3020.8390022675644
],
"Classic UI SFX - Short - Low #11": [
231000,
2458.3446712018144
],
"Classic UI SFX - Short - Low #12": [
235000,
2901.0430839002197
],
"Classic UI SFX - Short - Low #13": [
239000,
2843.7641723355964
],
"Classic UI SFX - Short - Low #14": [
243000,
3135.4195011337824
],
"Classic UI SFX - Short - Low #15": [
248000,
2703.1292517006877
],
"Classic UI SFX - Short - Low #16": [
252000,
2875.011337868472
],
"Classic UI SFX - Short - Low #17": [
256000,
2927.0975056689394
],
"Classic UI SFX - Short - Low #18": [
260000,
3057.2789115646515
],
"Classic UI SFX - Short - Low #19": [
265000,
2473.9455782312803
],
"Classic UI SFX - Short - Low #2": [
269000,
2583.3333333333144
],
"Classic UI SFX - Short - Low #20": [
273000,
2515.646258503409
],
"Classic UI SFX - Short - Low #21": [
277000,
2604.172335600879
],
"Classic UI SFX - Short - Low #22": [
281000,
3031.2698412698182
],
"Classic UI SFX - Short - Low #23": [
286000,
2937.50566893425
],
"Classic UI SFX - Short - Low #24": [
290000,
2609.387755102034
],
"Classic UI SFX - Short - Low #25": [
294000,
2625.0113378685
],
"Classic UI SFX - Short - Low #3": [
298000,
2828.140589569159
],
"Classic UI SFX - Short - Low #4": [
302000,
2614.6031746031895
6000,
2583.333333333334
],
"Classic UI SFX - Short - Low #5": [
306000,
3161.4739229024735
10000,
3161.473922902495
],
"Classic UI SFX - Short - Low #6": [
311000,
2333.3333333333144
"Classic UI SFX - Short - High #9": [
15000,
2250
],
"Classic UI SFX - Short - Low #7": [
315000,
2536.4625850340303
"UI_TwoNote Up_Set 11_01": [
21000,
129.16099773242706
],
"Classic UI SFX - Short - Low #8": [
319000,
2630.2267573695985
"UI_TwoNote Up_Set 11_02": [
23000,
250
],
"Classic UI SFX - Short - Low #9": [
323000,
2697.936507936504
"Classic UI SFX - Short - High #3": [
25000,
2864.6031746031754
],
"UI_Single_Set 16_01": [
327000,
309.5918367346826
"Classic UI SFX - Short - High #19": [
29000,
3052.0861678004535
],
"UI_Single_Set 16_02": [
329000,
309.5918367346826
"Classic UI SFX - Short - High #22": [
34000,
2489.5918367346967
],
"Classic UI SFX - Chords #16": [
38000,
4005.215419501134
],
"Classic UI SFX - Short - High #8": [
44000,
2916.6666666666642
],
"UI_Single_Set 16_03": [
331000,
309.5918367346826
48000,
309.5918367346968
],
"UI_TwoNote_Set 15_01": [
333000,
335.2380952380827
"UI_Single_Set 16_01": [
50000,
309.5918367346968
],
"UI_TwoNote_Set 15_02": [
335000,
309.5918367346826
"Classic UI SFX - Short - Low #6": [
52000,
2333.3333333333358
],
"UI SFX_InGameMenu_Open": [
56000,
2614.104308390026
]
}
}

BIN
src/mainview/assets/sounds.ogg (Stored with Git LFS)

Binary file not shown.

View file

@ -8,6 +8,8 @@ import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
import { GamepadButtonEvent } from "../scripts/gamepads";
export interface GameMetaExtra extends GameMeta
{
@ -24,10 +26,11 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
preview = data.game.previewUrl;
}
const handleAction = () =>
const handleAction = (e?: Event) =>
{
data.game.onSelect?.();
data.onAction?.();
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);

View file

@ -3,9 +3,9 @@ import { StickyHeaderUI } from './Header';
import { GameList } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './CardElement';
import { HandleGoBack } from '../scripts/utils';
@ -13,6 +13,7 @@ import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameQuery } from '../scripts/queries/romm';
import { useRouter } from '@tanstack/react-router';
import SelectMenu from './SelectMenu';
export interface CollectionsDetailParams
{
@ -43,8 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]);
const { shortcuts } = useShortcutContext();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
const handleScroll: GameCardFocusHandler = (cardId, node, details) =>
{
@ -83,9 +83,10 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div>
{data.footer}
</div>
<Shortcuts shortcuts={shortcuts} />
<FloatingShortcuts />
</footer>
</div>
<SelectMenu rootFocusKey={focusKey} />
</FocusContext>
);
}

View file

@ -7,6 +7,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
import { ContextDialogContext } from "../scripts/contexts";
import { FOCUS_KEYS } from "../scripts/types";
import { oneShot } from "../scripts/audio/audio";
import { oneShotRumble } from "../scripts/gamepads";
export function ContextList (data: {
options?: DialogEntry[];
@ -18,7 +19,7 @@ export function ContextList (data: {
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}>
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
<div className="divider m-0 "></div>
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
}
@ -85,9 +86,9 @@ export interface DialogEntry
shortcuts?: Shortcut[];
}
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; })
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; defaultOpen?: boolean; backdropClassName?: string; })
{
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(data.defaultOpen ?? false);
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
const handleClose = (value: boolean, newSourceFocusKey?: string) =>
{
@ -111,7 +112,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
}
};
const dialog = <ContextDialog id={id} open={open} close={handleClose} className={data.className} preferredChildFocusKey={data.preferredChildFocusKey}>
const dialog = <ContextDialog id={id} open={open} close={handleClose} backdropClassName={data.backdropClassName} className={data.className} preferredChildFocusKey={data.preferredChildFocusKey}>
{data.content}
</ContextDialog>;
return {
@ -127,12 +128,13 @@ export function ContextDialog (data: {
open: boolean,
close: (open: boolean) => void;
className?: string;
backdropClassName?: string;
preferredChildFocusKey?: string;
})
{
const { ref, focusKey, focusSelf } = useFocusable({
focusable: data.open,
focusKey: `${data.id}-context-dialog`,
focusKey: FOCUS_KEYS.CONTEXT_DIALOG(data.id),
isFocusBoundary: true,
saveLastFocusedChild: !data.preferredChildFocusKey,
preferredChildFocusKey: data.preferredChildFocusKey
@ -148,6 +150,7 @@ export function ContextDialog (data: {
{
focusSelf({ instant: true });
oneShot('openContext');
oneShotRumble('openContext', { all: true });
}
}, [data.open]);
@ -159,7 +162,7 @@ export function ContextDialog (data: {
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
classNames({ "opacity-0": !data.open }), data.backdropClassName)
}
onClick={handleClose}>
<FocusContext value={focusKey}>

View file

@ -1,7 +1,7 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import Shortcuts from "./Shortcuts";
import { FloatingShortcuts } from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { ErrorComponentProps, useRouter } from "@tanstack/react-router";
@ -12,7 +12,6 @@ export default function Error (data: ErrorComponentProps)
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf({ instant: true }); }, []);
@ -30,7 +29,7 @@ export default function Error (data: ErrorComponentProps)
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="mobile:hidden bg-dots"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
<FloatingShortcuts />
</FocusContext>
</div>;
}

View file

@ -51,7 +51,7 @@ export default function FocusDots (data: {
{
const focused = em === focusedKey;
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/60 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
});
@ -69,7 +69,7 @@ export default function FocusDots (data: {
}
}, [data.elements, data.scrollElement?.current]);
return <div className="divider opacity-20">
return <div className="divider opacity-40">
<div className="flex gap-2 py-6 justify-center items-center h-3">{elements}</div>
</div>;
}

View file

@ -1,10 +1,10 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router";
import { FloatingShortcuts } from "./Shortcuts";
export default function NotFound ()
{
@ -12,7 +12,6 @@ export default function NotFound ()
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf({ instant: true }); }, []);
@ -27,7 +26,7 @@ export default function NotFound ()
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="mobile:hidden bg-dots"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
<FloatingShortcuts />
</FocusContext>
</div>;
}

View file

@ -0,0 +1,106 @@
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types";
export default function SelectMenu (data: { rootFocusKey: string; })
{
const navigate = useNavigate();
const routeState = useRouterState();
const matchRoute = useMatchRoute();
const options: DialogEntry[] = [
{
content: "Home",
icon: <Gamepad2 />,
action (ctx)
{
setOpen(false);
navigate({ to: "/" });
},
selected: !!matchRoute({ to: '/' }),
type: "primary",
id: "home-m"
},
{
content: "Library",
icon: <Gamepad2 />,
action (ctx)
{
setOpen(false);
navigate({ to: "/games" });
},
selected: !!matchRoute({ to: '/games' }),
type: "secondary",
id: "library-m"
},
{
content: "Store",
icon: <Store />,
action (ctx)
{
setOpen(false);
navigate({ to: "/store/tab" });
},
selected: !!matchRoute({ to: '/store/tab' }),
type: "info",
id: "store-m"
},
{
content: "Settings",
icon: <Settings />,
action (ctx)
{
setOpen(false);
navigate({ to: "/settings/accounts" });
},
selected: !!matchRoute({ to: '/settings/accounts' }),
type: "accent",
id: "settings-m"
},
{
content: "Reload",
icon: <RefreshCcw />,
action (ctx)
{
setOpen(false);
navigation.reload();
},
type: "accent",
id: "reload-m"
},
{
content: "Quit",
icon: <DoorOpen />,
action (ctx)
{
systemApi.api.system.exit.post();
},
type: 'error',
id: "quit-m"
}
];
const { dialog, setOpen, open } = useContextDialog('select-menu', {
content: <ContextList showCloseButton={false} options={options} />,
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none',
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
});
useShortcuts(data.rootFocusKey, () => [{
label: "Menu", side: 'left', button: GamePadButtonCode.Select, action (e)
{
if (open)
{
setOpen(false);
} else
{
setOpen(true, getCurrentFocusKey());
}
},
}], [open]);
return <>{dialog}</>;
}

View file

@ -1,9 +1,16 @@
import { useContext } from 'react';
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
import { ShortcutsContext } from '../scripts/contexts';
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
export function FloatingShortcuts ()
{
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
}
export default function Shortcuts (data: { centerElement?: any; })
{
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
@ -47,15 +54,28 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
const { control } = useActiveControl();
const showKeyboard = control === 'keyboard' || control === 'mouse';
const { shortcuts } = useShortcutContext();
return (
<div className="flex gap-2 z-1000" style={{ viewTimelineName: "shortcuts" }}>
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
<>
<div className="flex gap-2 pointer-events-auto">
{shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
{data.centerElement}
<div className="flex gap-2 pointer-events-auto">
{shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
</>
);
}

View file

@ -21,7 +21,7 @@ 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 }, replace: true });
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
},
});
const ws = useRef<{ send: (data: string) => void; }>(undefined);
@ -108,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true });
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 });

View file

@ -9,13 +9,18 @@ export function LocalOption (data: {
label: string;
id: keyof LocalSettingsType;
type: HTMLInputTypeAttribute | 'dropdown';
min?: number;
max?: number;
step?: number;
placeholder?: string;
values?: string[];
icon?: JSX.Element;
children?: any;
})
{
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), {
deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v))
});
return (
<OptionSpace id={`${data.id}-space`} label={data.label}>
@ -25,30 +30,21 @@ export function LocalOption (data: {
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
setLocalValue(v);
}}
value={localValue} />}
{data.type !== 'dropdown' && <OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
min={data.min}
max={data.max}
step={data.step}
placeholder={data.placeholder}
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
setLocalValue(v);
}}
value={localValue}
/>}

View file

@ -1,10 +1,11 @@
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { CheckIcon, X } from "lucide-react";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
export function OptionInput (data: {
name: string;
@ -12,11 +13,14 @@ export function OptionInput (data: {
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string | boolean;
defaultValue?: string | boolean;
value?: string | boolean | number;
min?: number;
max?: number;
step?: number;
defaultValue?: string | boolean | number;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: (value: any) => void;
onChange?: (value: string | number | boolean) => void;
})
{
const handlePress = () =>
@ -30,16 +34,74 @@ export function OptionInput (data: {
}
oneShot('click');
};
const { ref } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
});
const [inputFocused, setInputFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { ref, focusKey } = useFocusable({
focusKey: data.name,
onEnterPress: handlePress,
onBlur: () => inputRef.current?.blur()
});
const option = useOptionContext({
onOptionEnterPress: handlePress,
});
const handleFocus = () =>
useEffect(() =>
{
if (data.type === 'range')
{
option.setFocusBoundary(inputFocused);
option.setFocusBoundaryDirections(['left', 'right']);
}
}, [inputFocused, option, data.type]);
useShortcuts(focusKey, () =>
{
const shortcuts: Shortcut[] = [];
if (inputFocused && data.type === 'range')
{
shortcuts.push(
{
label: "Decrease",
button: GamePadButtonCode.Left,
action ()
{
if (!inputRef.current) return;
inputRef.current?.stepDown();
data.onChange?.(inputRef.current.valueAsNumber);
}
},
{
label: "Increase",
button: GamePadButtonCode.Right,
action (e)
{
if (!inputRef.current) return;
inputRef.current?.stepUp();
data.onChange?.(inputRef.current.valueAsNumber);
}
}
);
}
if (inputFocused)
{
shortcuts.push({
label: "Unfocus",
button: GamePadButtonCode.B,
action (e)
{
inputRef.current?.blur();
}
});
}
return shortcuts;
}, [inputFocused, data.type]);
const handleInputFocus = () =>
{
option.focus();
setInputFocused(true);
if (inputRef.current)
{
var rect = inputRef.current?.getBoundingClientRect();
@ -52,25 +114,47 @@ export function OptionInput (data: {
}
};
const handleInputBlur = (e: any) =>
{
data.onBlur?.(e);
setInputFocused(false);
};
return (
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
min={data.min}
max={data.max}
step={data.step}
data-focus={"input"}
name={data.name}
value={String(data.value)}
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
type={data.type}
autoComplete={data.autocomplete}
onFocus={handleFocus}
onFocus={handleInputFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
onBlur={data.onBlur}
onChange={e =>
{
if (typeof data.defaultValue === 'boolean')
{
data.onChange?.(e.target.checked);
} else if (data.type === 'range')
{
data.onChange?.(e.target.valueAsNumber);
} else
{
data.onChange?.(e.target.value);
}
}}
onBlur={handleInputBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
"flex text-base-content px-4 py-2 items-center justify-center border bg-base-200 border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-100 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
data.type === 'range' ? "range" : "",
data.className
)}
/>}
@ -83,10 +167,10 @@ export function OptionInput (data: {
type={data.type}
onClick={() => { oneShot("click"); }}
autoComplete={data.autocomplete}
onFocus={handleFocus}
onFocus={handleInputFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(e.target.checked)}
onBlur={data.onBlur}
onBlur={handleInputBlur}
className={twMerge(
"active:bg-base-content rounded-full",
data.className

View file

@ -1,7 +1,7 @@
import { OptionContext } from "@/mainview/scripts/contexts";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Direction, FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX, useContext, useEffect, useMemo } from "react";
import { JSX, useContext, useEffect, useMemo, useState } from "react";
import { twMerge } from "tailwind-merge";
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
@ -40,12 +40,16 @@ export function OptionSpace (data: {
saveLastFocusedChild?: boolean;
})
{
const [focusBoundary, setFocusBoundary] = useState(false);
const [focusBoundaryDirections, setFocusBoundaryDirections] = useState<Direction[]>([]);
const eventTarget = useMemo(() => new EventTarget(), []);
const { ref, focused, focusSelf, focusKey } = useFocusable({
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
isFocusBoundary: focusBoundary,
focusBoundaryDirections,
onFocus ()
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -71,7 +75,7 @@ export function OptionSpace (data: {
}
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<OptionContext value={{ focused, focus: focusSelf, setFocusBoundary, setFocusBoundaryDirections, eventTarget }}>
<li
ref={ref}
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5 focused:bg-base-300 focused-child:bg-base-300",

View file

@ -7,7 +7,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
import { ButtonStyle } from '../components/options/Button';
import { DoorOpen, RefreshCw, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import Shortcuts from '../components/Shortcuts';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { useEventListener } from 'usehooks-ts';
import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge';
@ -17,6 +17,9 @@ import { gameQuery } from '@queries/romm';
export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent,
staticData: {
enterSound: 'launch'
},
loader: async (ctx) =>
{
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
@ -133,7 +136,13 @@ function RouteComponent ()
function HandleGoBack ()
{
router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true });
if (router.history.canGoBack())
{
router.history.back();
} else
{
router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true });
}
}
useEventListener('message', e =>
@ -172,7 +181,6 @@ function RouteComponent ()
}
};
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
const { shortcuts } = useShortcutContext();
useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]);
function handleClose ()
{
@ -185,9 +193,7 @@ function RouteComponent ()
<div className='flex fixed left-0 right-0 top-0'>
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
</div>
<div className='flex justify-end fixed bottom-4 right-4 left-4 z-10'>
<Shortcuts shortcuts={shortcuts} />
</div>
<FloatingShortcuts />
</FocusContext>
</div>;
}

View file

@ -6,7 +6,7 @@ import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "
import { HeaderUI, StickyHeaderUI } from "../../components/Header";
import { AnimatedBackground } from "../../components/AnimatedBackground";
import { useQuery } from "@tanstack/react-query";
import Shortcuts from "../../components/Shortcuts";
import Shortcuts, { FloatingShortcuts } from "../../components/Shortcuts";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Screenshots from "@/mainview/components/Screenshots";
import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils";
@ -22,6 +22,7 @@ import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm";
import { GamesSection } from "@/mainview/components/store/GamesSection";
import Details from "@/mainview/components/game/Details";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import SelectMenu from "@/mainview/components/SelectMenu";
export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) =>
@ -47,8 +48,7 @@ function Error (data: ErrorComponentProps)
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
const router = useRouter();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }]);
const { shortcuts } = useShortcutContext();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }]);
return <AnimatedBackground ref={ref} backgroundKey="game-details">
<div className="relative z-10 h-full">
@ -60,12 +60,6 @@ function Error (data: ErrorComponentProps)
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {JSON.stringify(data.error, null, 3)}</div>
</div>
<div className="bg-base-200">
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
<AutoFocus force focus={focusSelf} />
@ -151,13 +145,11 @@ function RouteComponent ()
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
useShortcuts(focusKey, () => [{
label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router)
label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e)
}], [router]);
useOnNavigateBack((s) => s.sound = 'returnDetails');
const { shortcuts } = useShortcutContext();
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists));
const { ref: intersct } = useIntersectionObserver({
@ -211,11 +203,10 @@ function RouteComponent ()
}} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} />
</div>
</div>
<SelectMenu rootFocusKey={focusKey} />
</FocusContext>
</div>
<footer className="fixed right-0 bottom-0 p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
<FloatingShortcuts />
</GameDetailsContext>
</AnimatedBackground>
);

View file

@ -34,7 +34,6 @@ import { AutoFocus } from "../components/AutoFocus";
import SaveScroll from "../components/SaveScroll";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
import { twMerge } from "tailwind-merge";
import Shortcuts from "../components/Shortcuts";
import { PlatformsList } from "../components/PlatformsList";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import z from "zod";
@ -46,6 +45,8 @@ import Carousel from "../components/Carousel";
import { closeMutation } from "@queries/system";
import { gameQuery } from "../scripts/queries/romm";
import { oneShot } from "../scripts/audio/audio";
import { FloatingShortcuts } from "../components/Shortcuts";
import SelectMenu from "../components/SelectMenu";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@ -94,7 +95,7 @@ function ShowAllGamesCard ()
const router = useRouter();
const handleNavigate = () =>
{
router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } });
router.navigate({ to: '/games' });
};
const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate });
return <div ref={ref} onClick={handleNavigate} className="flex focusable focusable-primary justify-center items-center bg-base-300/80 rounded-3xl font-semibold w-(--game-card-width) h-(--game-card-height) focusable-hover cursor-pointer">All Games</div>;
@ -231,22 +232,22 @@ function MainMenu ()
>
<FocusContext.Provider value={focusKey}>
<CircleIcon
action={() => router.navigate({ to: "/games" })}
onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.type } })}
icon={<Gamepad2 />}
label="Home"
type="secondary"
/>
<CircleIcon icon={<MessageSquare />} label="News" />
<CircleIcon type="info" icon={<Store />} action={() => router.navigate({ to: "/store/tab" })} label="Shop" />
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" />
<CircleIcon icon={<Image />} label="Album" />
<CircleIcon
icon={<Gamepad2 />}
label="Controllers"
/>
<CircleIcon
action={() =>
onAction={(e) =>
{
router.navigate({ to: '/settings/accounts' });
router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } });
}}
icon={<Settings />}
label="Settings"
@ -258,15 +259,14 @@ function MainMenu ()
}
function CircleIcon (data: {
action?: () => void;
type?: "secondary" | "accent" | "info";
label?: string;
icon?: JSX.Element;
})
} & InteractParams)
{
const handleAction = () =>
const handleAction = (e?: Event) =>
{
data.action?.();
data.onAction?.(e);
oneShot('click');
};
const { ref, focusKey } = useFocusable({
@ -284,7 +284,7 @@ function CircleIcon (data: {
<li
ref={ref}
data-sound-category={"menu"}
onClick={handleAction}
onClick={e => handleAction(e.nativeEvent)}
className={twMerge(
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])}
>
@ -309,7 +309,6 @@ export default function ConsoleHomeUI ()
const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true });
const { shortcuts } = useShortcutContext();
const headerButtons: HeaderButton[] = [];
if (mobileCheck())
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
@ -348,9 +347,9 @@ export default function ConsoleHomeUI ()
<footer className={twMerge(
"fixed bottom-4 left-4 right-4 sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 flex items-end justify-end",
)}>
<Shortcuts shortcuts={shortcuts} />
<FloatingShortcuts />
</footer>
<SelectMenu rootFocusKey={focusKey} />
</FocusContext.Provider>
</AnimatedBackground>
);

View file

@ -3,11 +3,14 @@ import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router';
import DotsLoading from '../components/backgrounds/dots';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts from '../components/Shortcuts';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { useJobStatus } from '../scripts/utils';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
staticData: {
enterSound: 'launch'
},
});
function RouteComponent ()
@ -28,7 +31,6 @@ function RouteComponent ()
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
const { data } = useJobStatus('launch-game', {
onEnded (data)
@ -48,8 +50,6 @@ function RouteComponent ()
<DotsLoading />
<h1 className='font-semibold'>Launching {data?.name} ...</h1>
</div>
<div className='absolute bot'>
<Shortcuts shortcuts={shortcuts} />
</div>
<FloatingShortcuts />
</AnimatedBackground>;
}

View file

@ -20,6 +20,8 @@ function RouteComponent ()
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption>
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
<LocalOption id='soundEffects' label="Sounds" type='checkbox'></LocalOption>
<LocalOption id='soundEffectsVolume' min={0} max={100} step={10} label="Sounds" type='range'></LocalOption>
<LocalOption id='hapticsEffects' label="Haptics" type='checkbox'></LocalOption>
</FocusContext>
</ul>;
}

View file

@ -27,10 +27,11 @@ import { twMerge } from "tailwind-merge";
import z from "zod";
import { SettingsSchema } from "../../../shared/constants";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
import { HandleGoBack } from "@/mainview/scripts/utils";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import { oneShot } from "@/mainview/scripts/audio/audio";
import SelectMenu from "@/mainview/components/SelectMenu";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
@ -55,11 +56,11 @@ function MenuItem (data: {
{
const router = useRouter();
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
const handleNonFocusSelect = () =>
const handleNonFocusSelect = (e?: Event) =>
{
if (data.return)
{
HandleGoBack(router);
HandleGoBack(router, e);
} else if (!acitve)
{
router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
@ -88,7 +89,7 @@ function MenuItem (data: {
ref={ref}
key={data.route}
data-sound-category={"menu"}
onClick={data.focusSelect ? focusSelf : handleNonFocusSelect}
onClick={data.focusSelect ? focusSelf : (e) => handleNonFocusSelect(e.nativeEvent)}
onFocus={focusSelf}
className={twMerge("flex group-focusable cursor-pointer", data.className)}
>
@ -180,8 +181,7 @@ export function SettingsUI ()
preferredChildFocusKey: 'settings-menu'
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]);
const { shortcuts } = useShortcutContext();
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
return (
<FocusContext.Provider value={focusKey}>
@ -195,10 +195,13 @@ export function SettingsUI ()
<Outlet />
</div>
</div>
<div className="portrait:hidden divider divider-end">
<Shortcuts shortcuts={shortcuts} />
<div className="flex justify-between pt-2">
<Shortcuts centerElement={
<div className="divider divider-vertical grow px-4"></div>
} />
</div>
</div>
<SelectMenu rootFocusKey={focusKey} />
<AutoFocus focus={focusSelf} />
</FocusContext.Provider>
);

View file

@ -6,7 +6,7 @@ import
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button";
@ -335,7 +335,7 @@ export function RouteComponent ()
useShortcuts(focusKey, () => [{
label: "Return",
action: () => HandleGoBack(router),
action: (e) => HandleGoBack(router, e),
button: GamePadButtonCode.B
}], [router]);
@ -344,8 +344,6 @@ export function RouteComponent ()
onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)),
});
const { shortcuts } = useShortcutContext();
const stats: StatEntry[] = [];
if (emulator)
{
@ -434,7 +432,7 @@ export function RouteComponent ()
}} games={recommendedGames} /></div>}
</div>
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
<Shortcuts shortcuts={shortcuts} />
<FloatingShortcuts />
</div>
</FocusContext.Provider>
</AnimatedBackground >

View file

@ -56,11 +56,11 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable md:px-12 p-4 mt-4 gap-6'>
<FocusContext value={focusKey}>
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-lg w-full ring-6 ring-base-200 border-6 border-base-200">
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
<div className='flex relative h-full overflow-hidden'>
<div className='absolute w-full h-full z-0 bg-base-200'>
<img key={selectedGame}
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-100 z-0 mask-l-from-0'
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-80 z-0'
src={previewUrl?.href}
onLoad={(e) =>
{
@ -72,13 +72,13 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden'>
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg'><HardDrive /></div>
{!!data.games && <img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />}
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden shadow-lg'>
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg '><HardDrive /></div>
{!!data.games && <img className='object-cover w-full h-full ' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />}
</div>
<div className='flex flex-col gap-2 py-3 max-w-md'>
<h1 className='font-semibold text-3xl'>{game.name}</h1>
<p className='overflow-hidden text-wrap text-ellipsis text-base-content/60'>{game.summary}</p>
<h1 className='font-semibold text-3xl text-shadow-md'>{game.name}</h1>
<p className='overflow-hidden text-wrap text-ellipsis text-base-content/60 text-shadow-md'>{game.summary}</p>
</div>
</div>
</div>
@ -140,7 +140,7 @@ export function RouteComponent ()
<div className="px-6 py-3">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-sm" />
<div className="w-2 h-5 rounded-full bg-accent shadow-sm" />
<Gamepad2 className="text-accent text-shadow-sm" />
<h2 className="font-bold uppercase tracking-widest text-accent grow text-shadow-sm">
Featured Games

View file

@ -1,7 +1,8 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import { FilterUI } from '@/mainview/components/Filters';
import { HeaderUI } from '@/mainview/components/Header';
import Shortcuts from '@/mainview/components/Shortcuts';
import SelectMenu from '@/mainview/components/SelectMenu';
import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts';
import { gameQuery } from '@/mainview/scripts/queries/romm';
import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store';
@ -19,7 +20,8 @@ export const Route = createFileRoute('/store/tab')({
component: RouteComponent,
validateSearch: zodValidator(z.object({ focus: z.string().optional() })),
staticData: {
enterSound: 'openStore'
enterSound: 'openStore',
enterHaptic: 'navigateStore'
}
});
@ -47,7 +49,7 @@ function TopArea (data: { filters: Record<string, FilterOption>; })
useShortcuts("STORE_ROOT", () => [{
label: "Return",
action: () => HandleGoBack(router),
action: (e) => HandleGoBack(router, e),
button: GamePadButtonCode.B
}], [router]);
@ -94,8 +96,6 @@ function RouteComponent ()
games: { label: "Games", selected: useIsSettings('games') }
};
const { shortcuts } = useShortcutContext();
const handleDetails = (type: string, source: string, id: string, focus: string) =>
{
if (type === 'emulator')
@ -133,17 +133,16 @@ function RouteComponent ()
</div>
<TopArea filters={filters} />
<StoreOutlet />
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
<Shortcuts shortcuts={shortcuts} />
</div>
{!isMobile && <>
<div className='bg-gradient'></div>
<div className='bg-noise'></div>
<div className='bg-dots'></div>
</>}
</div>
<SelectMenu rootFocusKey={focusKey} />
</FocusContext.Provider>
</StoreContext>
<AutoFocus focus={focusSelf} />
<FloatingShortcuts />
</div >;
}

View file

@ -2,46 +2,36 @@ import { Howl } from 'howler';
import sounds from '../../assets/sounds.ogg';
import soundSprites from '../../assets/sounds.json';
import { getLocalSetting } from '../utils';
import { hapticMap } from '../gamepads';
import { soundMap } from './audioConstants';
const timingMap = new Map<string, Date>();
// Browsers need input to start any sound, so intro doesn't auto play.
/*const introSound = new Howl({
src: [intro],
volume: getLocalSetting("soundEffectsVolume") / 100,
autoplay: true,
});*/
const sound = new Howl({
src: [sounds],
sprite: soundSprites.sprite as any,
volume: 0.5,
volume: getLocalSetting("soundEffectsVolume") / 100,
});
import.meta.hot?.dispose(() => { sound.unload(); });
declare module '@tanstack/react-router' {
interface StaticDataRouteOption
{
enterSound?: keyof typeof soundMap | null;
enterHaptic?: keyof typeof hapticMap | null;
goBackSound?: keyof typeof soundMap | null;
}
}
const volumeVariation = 0.05;
const rateVariation = 0.01;
export const soundMap = {
openDetails: { key: 'Classic UI SFX - Chords #1' },
returnGeneric: { key: 'Classic UI SFX - Short - Low #2' },
returnDetails: { key: 'Classic UI SFX - Short - Low #5' },
openGeneric: { key: 'Classic UI SFX - Short - High #9' },
select: { key: 'Classic UI SFX - Short - High #5', rateVariation, volumeVariation },
selectAlt: { key: "Classic UI SFX - Short - High #6", rateVariation, volumeVariation },
selectMenu: { key: 'Classic UI SFX - Short - High #7', rateVariation, volumeVariation },
selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation },
closeContext: { key: 'Classic UI SFX - Short - High #19' },
openContext: { key: 'Classic UI SFX - Short - High #22' },
openStore: { key: 'Classic UI SFX - Chords #16' },
openSettings: { key: 'Classic UI SFX - Short - High #8' },
click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation },
clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation },
invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation },
} satisfies Record<string, { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }>;
function sinRanom ()
function sinRandom ()
{
return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI);
}
@ -63,8 +53,9 @@ export function oneShot (id: keyof typeof soundMap)
if (currentDate && new Date().getTime() - currentDate.getTime() <= 100) return;
const soundValue = soundMap[id] as { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; };
const instanceId = sound.play(soundValue.key);
sound.volume(sound.volume() + random() * (soundValue.volumeVariation ?? 0), instanceId);
sound.rate(1 + random() * (soundValue.rateVariation ?? 0), instanceId);
const baseVolume = getLocalSetting("soundEffectsVolume") / 100;
sound.volume(Math.min(baseVolume * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId);
sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId);
timingMap.set(id, new Date());
}

View file

@ -0,0 +1,23 @@
import soundSprites from '../../assets/sounds.json';
const volumeVariation = 0.05;
const rateVariation = 0.02;
export const soundMap = {
openDetails: { key: 'Classic UI SFX - Chords #2' },
returnGeneric: { key: 'Classic UI SFX - Short - Low #2' },
returnDetails: { key: 'Classic UI SFX - Short - Low #5' },
openGeneric: { key: 'Classic UI SFX - Short - High #9' },
select: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation },
selectAlt: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation },
selectMenu: { key: "UI_TwoNote Up_Set 11_02", rateVariation, volumeVariation },
selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation },
closeContext: { key: 'Classic UI SFX - Short - High #19' },
openContext: { key: 'Classic UI SFX - Short - High #22' },
openStore: { key: 'Classic UI SFX - Chords #16' },
openSettings: { key: 'Classic UI SFX - Short - High #8' },
click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation },
clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation },
invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation },
launch: { key: "UI SFX_InGameMenu_Open" }
} satisfies Record<string, { key: keyof typeof soundSprites.sprite, rateVariation?: number; volumeVariation?: number; }>;

View file

@ -1,6 +1,7 @@
import { SystemInfoType } from "@/shared/constants";
import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
import { createContext } from "react";
import { Shortcut } from "./shortcuts";
export const StoreContext = createContext({} as {
showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void;
@ -20,6 +21,8 @@ export const OptionContext = createContext(
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
setFocusBoundary: (b: boolean) => void;
setFocusBoundaryDirections: (dirs: Direction[]) => void;
},
);
@ -34,6 +37,12 @@ export const FilePickerContext = createContext<{
activeDrive: Drive | undefined;
}>({} as any);
export const ShortcutsContext = createContext({} as {
shortcuts: ({
key: string;
} & Shortcut)[] | undefined;
});
export const SystemInfoContext = createContext({} as SystemInfoType | undefined);
export const GameDetailsContext = createContext<{

View file

@ -1,5 +1,8 @@
import { Router } from "@/mainview";
import { oneShot, soundMap } from "./audio";
import { soundMap } from "./audio/audioConstants";
import { oneShotRumble } from "./gamepads";
import { oneShot } from "./audio/audio";
export default function load ()
{
let lastLocationPath: string | undefined;
@ -13,12 +16,18 @@ export default function load ()
const soundRoute = routes.find(r => r.staticData.enterSound !== undefined);
if (soundRoute)
{
if (soundRoute.staticData.enterSound) oneShot(soundRoute.staticData.enterSound);
oneShot(soundRoute.staticData.enterSound!);
} else
{
oneShot("openGeneric");
}
if (op.location.state.eventType === 'gamepadbuttondown')
{
const hapticRoute = routes.find(r => r.staticData.enterHaptic !== undefined);
if (hapticRoute) oneShotRumble(hapticRoute.staticData.enterHaptic!, { all: true });
else oneShotRumble('navigateForward', { all: true });
}
} else if (op.action.type === 'BACK')
{
if (lastLocationPath)
@ -73,6 +82,7 @@ export default function load ()
if (e.detail.nativeEvent || e.detail.event)
{
oneShot(sound);
oneShotRumble('select', { event: e.detail.event });
}
}, 10);
}

View file

@ -1,7 +1,7 @@
import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react";
import { mobileCheck } from "./utils";
import { getLocalSetting, mobileCheck } from "./utils";
import { oneShot } from "./audio/audio";
let loopStarted = false;
@ -280,4 +280,45 @@ function updateStatus ()
}
requestAnimationFrame(updateStatus);
}
export const hapticMap = {
select: [{ duration: 50, strongMagnitude: 0, weakMagnitude: 1 }],
navigateForward: [{ duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }, { duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }],
navigateBack: [{ duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }],
navigateStore: [{ duration: 200, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 300, strongMagnitude: 0.2, weakMagnitude: 0.2 }],
openContext: [{ duration: 50, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.0, weakMagnitude: 0.0 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }],
} satisfies Record<string, GamepadEffectParameters[]>;
let lastRumble: AbortController;
export function oneShotRumble (effect: keyof typeof hapticMap, init?: { event?: Event, all?: boolean; })
{
if (!getLocalSetting('hapticsEffects')) return;
async function play (g: Gamepad)
{
lastRumble = new AbortController();
for (const e of hapticMap[effect])
{
await new Promise(resolve =>
{
g.vibrationActuator.playEffect('dual-rumble', e);
const timeout = setTimeout(() => resolve(true), e.duration + 50);
lastRumble.signal.onabort = () => clearTimeout(timeout);
if (lastRumble.signal.aborted) resolve(false);
});
if (lastRumble.signal.aborted) return;
}
}
if (lastRumble) lastRumble.abort();
if (init?.event instanceof GamepadEvent || init?.event instanceof GamepadButtonEvent)
{
if (init?.event.gamepad) play(init?.event.gamepad);
} else if (init?.all)
{
navigator.getGamepads().filter(g => !!g).forEach(g => play(g));
}
}

View file

@ -34,6 +34,7 @@ export interface Shortcut
button: GamePadButtonCode;
heldTime?: number;
action?: (e: GamepadButtonEvent) => void;
side?: "left" | "right";
}
let isDirty = false;

View file

@ -32,7 +32,7 @@ export function GetFocusedElement (focusKey: string)
export function GetFocusedTree (leaf: string): string[]
{
const tree: string[] = [];
const tree: string[] = ["window"];
let component = (SpatialNavigation as any).focusableComponents[leaf];
while (component)
{

View file

@ -6,6 +6,7 @@ export const FOCUS_KEYS = {
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
EMULATOR_CUSTOM_PATH: (id: string) => `EMULATOR_CUSTOM_PATH_${id}`,
CONTEXT_DIALOG_OPTION: (contextId: string, id: string) => `${contextId}_LIST_OPTION${id}`,
CONTEXT_DIALOG: (contextId: string) => `${contextId}_CONTEXT_DIALOG`,
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,

View file

@ -4,7 +4,8 @@ import { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc";
import { AnyRouter, useRouter } from "@tanstack/react-router";
import { soundMap } from "./audio/audio";
import { soundMap } from "./audio/audioConstants";
import { GamepadButtonEvent, oneShotRumble } from "./gamepads";
export type ScrollSaveParams = {
id: string;
@ -60,11 +61,11 @@ export function mobileCheck ()
return check;
};
export function getLocalSetting<TKey extends keyof LocalSettingsType> (key: TKey)
export function getLocalSetting<TKey extends keyof LocalSettingsType> (key: TKey): LocalSettingsType[TKey]
{
const localValueRaw = localStorage.getItem(key);
if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined);
return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw));
if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined) as any;
return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)) as any;
}
export function useLocalSetting<TKey extends keyof LocalSettingsType> (key: TKey)
@ -329,11 +330,15 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
return { data, state, error, wsRef: ref };
}
export function HandleGoBack (router: AnyRouter)
export function HandleGoBack (router: AnyRouter, e?: Event)
{
if (router.history.canGoBack())
{
router.history.back();
if (e instanceof GamepadButtonEvent)
{
oneShotRumble("navigateBack", { event: e });
}
} else
{
router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } });

View file

@ -24,6 +24,14 @@ declare global
sound?: string;
}
}
module "@tanstack/react-router" {
declare interface HistoryState
{
eventType?: string;
}
}
}
declare interface FocusEventDetails

View file

@ -44,7 +44,9 @@ export const LocalSettingsSchema = z.object({
backgroundBlur: z.stringbool().or(z.boolean()).default(true),
backgroundAnimation: z.stringbool().or(z.boolean()).default(true),
theme: z.enum(['dark', 'light', 'auto']).default('auto'),
soundEffects: z.boolean().default(true)
soundEffects: z.boolean().default(true),
soundEffectsVolume: z.number().min(0).max(100).default(50),
hapticsEffects: z.boolean().default(true)
});
export const GameListFilterSchema = z.object({

BIN
src/sounds/UI SFX_InGameMenu_Open.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Flourish Down_Set 14_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Flourish Up_Set 14_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 11_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 11_02.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 11_03.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_Single_Set 5_02.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Down_Set 11_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Down_Set 14_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Up_Set 11_01.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Up_Set 11_02.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Up_Set 11_03.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/sounds/UI_TwoNote Up_Set 14_01.wav (Stored with Git LFS) Normal file

Binary file not shown.