feat: implemented haptics
feat: Implemented a select menu fix: Only used audio clips compile
This commit is contained in:
parent
02a4f2c9a9
commit
54dd9256e3
51 changed files with 580 additions and 466 deletions
|
|
@ -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
BIN
src/mainview/assets/intro.ogg
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -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)
BIN
src/mainview/assets/sounds.ogg
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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 }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
106
src/mainview/components/SelectMenu.tsx
Normal file
106
src/mainview/components/SelectMenu.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 >
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 >;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
23
src/mainview/scripts/audio/audioConstants.ts
Normal file
23
src/mainview/scripts/audio/audioConstants.ts
Normal 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; }>;
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ export interface Shortcut
|
|||
button: GamePadButtonCode;
|
||||
heldTime?: number;
|
||||
action?: (e: GamepadButtonEvent) => void;
|
||||
side?: "left" | "right";
|
||||
}
|
||||
|
||||
let isDirty = false;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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'] } });
|
||||
|
|
|
|||
8
src/mainview/types.d.ts
vendored
8
src/mainview/types.d.ts
vendored
|
|
@ -24,6 +24,14 @@ declare global
|
|||
sound?: string;
|
||||
}
|
||||
}
|
||||
|
||||
module "@tanstack/react-router" {
|
||||
declare interface HistoryState
|
||||
{
|
||||
eventType?: string;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
declare interface FocusEventDetails
|
||||
|
|
|
|||
|
|
@ -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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
src/sounds/UI_TwoNote Up_Set 14_01.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue