From 54dd9256e361877d0950a84061d9402616706352 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 7 Apr 2026 15:28:56 +0300 Subject: [PATCH] feat: implemented haptics feat: Implemented a select menu fix: Only used audio clips compile --- README.md | 1 + scripts/generate-audio-sprites.ts | 29 +- src/mainview/App.tsx | 2 +- src/mainview/assets/intro.ogg | 3 + src/mainview/assets/sounds.json | 326 +++--------------- src/mainview/assets/sounds.ogg | 4 +- src/mainview/components/CardList.tsx | 5 +- src/mainview/components/CollectionsDetail.tsx | 11 +- src/mainview/components/ContextDialog.tsx | 15 +- src/mainview/components/Error.tsx | 5 +- src/mainview/components/FocusDots.tsx | 4 +- src/mainview/components/NotFound.tsx | 5 +- src/mainview/components/SelectMenu.tsx | 106 ++++++ src/mainview/components/Shortcuts.tsx | 42 ++- src/mainview/components/game/MainActions.tsx | 4 +- .../components/options/LocalOption.tsx | 26 +- .../components/options/OptionInput.tsx | 110 +++++- .../components/options/OptionSpace.tsx | 10 +- src/mainview/routes/embedded.$source.$id.tsx | 18 +- src/mainview/routes/game/$source.$id.tsx | 21 +- src/mainview/routes/index.tsx | 27 +- src/mainview/routes/launcher.$source.$id.tsx | 10 +- src/mainview/routes/settings/interface.tsx | 2 + src/mainview/routes/settings/route.tsx | 19 +- .../routes/store/details.emulator.$id.tsx | 8 +- src/mainview/routes/store/tab/index.tsx | 16 +- src/mainview/routes/store/tab/route.tsx | 15 +- src/mainview/scripts/audio/audio.ts | 41 +-- src/mainview/scripts/audio/audioConstants.ts | 23 ++ src/mainview/scripts/contexts.ts | 11 +- ...audioCallbacks.ts => feedbackCallbacks.ts} | 14 +- src/mainview/scripts/gamepads.ts | 43 ++- src/mainview/scripts/shortcuts.ts | 1 + src/mainview/scripts/spatialNavigation.ts | 2 +- src/mainview/scripts/types.ts | 1 + src/mainview/scripts/utils.ts | 15 +- src/mainview/types.d.ts | 8 + src/shared/constants.ts | 4 +- src/sounds/UI SFX_InGameMenu_Open.ogg | 3 + src/sounds/UI_Flourish Down_Set 14_01.wav | 3 + src/sounds/UI_Flourish Up_Set 14_01.wav | 3 + src/sounds/UI_Single_Set 11_01.wav | 3 + src/sounds/UI_Single_Set 11_02.wav | 3 + src/sounds/UI_Single_Set 11_03.wav | 3 + src/sounds/UI_Single_Set 5_02.wav | 3 + src/sounds/UI_TwoNote Down_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Down_Set 14_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_01.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_02.wav | 3 + src/sounds/UI_TwoNote Up_Set 11_03.wav | 3 + src/sounds/UI_TwoNote Up_Set 14_01.wav | 3 + 51 files changed, 580 insertions(+), 466 deletions(-) create mode 100644 src/mainview/assets/intro.ogg create mode 100644 src/mainview/components/SelectMenu.tsx create mode 100644 src/mainview/scripts/audio/audioConstants.ts rename src/mainview/scripts/{audio/audioCallbacks.ts => feedbackCallbacks.ts} (81%) create mode 100644 src/sounds/UI SFX_InGameMenu_Open.ogg create mode 100644 src/sounds/UI_Flourish Down_Set 14_01.wav create mode 100644 src/sounds/UI_Flourish Up_Set 14_01.wav create mode 100644 src/sounds/UI_Single_Set 11_01.wav create mode 100644 src/sounds/UI_Single_Set 11_02.wav create mode 100644 src/sounds/UI_Single_Set 11_03.wav create mode 100644 src/sounds/UI_Single_Set 5_02.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Down_Set 14_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_01.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_02.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 11_03.wav create mode 100644 src/sounds/UI_TwoNote Up_Set 14_01.wav diff --git a/README.md b/README.md index 040a745..e3b2c46 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,4 @@ Focused on building a simple user experience and intuitive UI as a curated commu - UI Sounds - [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1) - [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx) + - [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx) diff --git a/scripts/generate-audio-sprites.ts b/scripts/generate-audio-sprites.ts index bcce3e8..1625362 100644 --- a/scripts/generate-audio-sprites.ts +++ b/scripts/generate-audio-sprites.ts @@ -1,24 +1,31 @@ import audioSprite from 'audiosprite'; -import { $, which } from 'bun'; -import fs from "node:fs/promises"; +import { $ } from 'bun'; import path from 'node:path'; +import { soundMap } from '../src/mainview/scripts/audio/audioConstants'; -var files = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +const files = Object.values(soundMap).map(v => +{ + const existingFile = allFiles.find(f => f.startsWith(v.key)); + if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`); + const filePath = path.join(path.resolve('./src/sounds'), existingFile); + return filePath; +}); console.log("Loaded", files.join(",")); await new Promise((resolve) => { - audioSprite( - files.map(f => path.join(path.resolve('./src/sounds'), f)), + audioSprite(files, { output: path.resolve('./src/mainview/assets/sounds'), path: path.resolve('./src/sounds'), format: 'howler', export: 'ogg' - }, async function (err, obj: any) - { - if (err) return console.error(err); - delete obj.urls; - Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); - }); + }, + async function (err, obj: any) + { + if (err) return console.error(err); + delete obj.urls; + Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); + }); }); \ No newline at end of file diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index fb0d2db..76dcda3 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -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"; diff --git a/src/mainview/assets/intro.ogg b/src/mainview/assets/intro.ogg new file mode 100644 index 0000000..e1505f9 --- /dev/null +++ b/src/mainview/assets/intro.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:231ac69f71f4a0a770ae4bbfd42db9ea136dad6813ddae68a211c74a16e21778 +size 74296 diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json index ae3bae3..97b63f9 100644 --- a/src/mainview/assets/sounds.json +++ b/src/mainview/assets/sounds.json @@ -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 ] } } \ No newline at end of file diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg index b8e00d5..0b7ac9b 100644 --- a/src/mainview/assets/sounds.ogg +++ b/src/mainview/assets/sounds.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a3bb2f9a59e20e5ea49fec7fca68cda5c9167df332ff25d24c29870af834af7 -size 2229386 +oid sha256:c5dd2b1e23a878efe84694fa354e92e07f9394d88217b0f1d925f3b16f044e55 +size 353897 diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 0518d2c..2744585 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -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 }]); diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index ac0437f..717e986 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -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)
{data.footer}
- + + ); } \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 94d31a8..6024311 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -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
    {data.options?.map(o => )} -
    + {data.showCloseButton !== false &&
    } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
; } @@ -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(undefined); const handleClose = (value: boolean, newSourceFocusKey?: string) => { @@ -111,7 +112,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla } }; - const dialog = + const dialog = {data.content} ; 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 diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index 6bafd95..81bcb63 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -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)
-
+
; } \ No newline at end of file diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 0fc2af1..192e388 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -51,7 +51,7 @@ export default function FocusDots (data: { { const focused = em === focusedKey; return ; }); @@ -69,7 +69,7 @@ export default function FocusDots (data: { } }, [data.elements, data.scrollElement?.current]); - return
+ return
{elements}
; } \ No newline at end of file diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index 0172729..6985609 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -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 ()
-
+
; } \ No newline at end of file diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx new file mode 100644 index 0000000..4e68b68 --- /dev/null +++ b/src/mainview/components/SelectMenu.tsx @@ -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: , + action (ctx) + { + setOpen(false); + navigate({ to: "/" }); + }, + selected: !!matchRoute({ to: '/' }), + type: "primary", + id: "home-m" + }, + { + content: "Library", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/games" }); + }, + selected: !!matchRoute({ to: '/games' }), + type: "secondary", + id: "library-m" + }, + { + content: "Store", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/store/tab" }); + }, + selected: !!matchRoute({ to: '/store/tab' }), + type: "info", + id: "store-m" + }, + { + content: "Settings", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/accounts" }); + }, + selected: !!matchRoute({ to: '/settings/accounts' }), + type: "accent", + id: "settings-m" + }, + { + content: "Reload", + icon: , + action (ctx) + { + setOpen(false); + navigation.reload(); + }, + type: "accent", + id: "reload-m" + }, + { + content: "Quit", + icon: , + action (ctx) + { + systemApi.api.system.exit.post(); + }, + type: 'error', + id: "quit-m" + } + ]; + const { dialog, setOpen, open } = useContextDialog('select-menu', { + content: , + 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}; +} \ No newline at end of file diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index d8fc94c..03d5e03 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -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
; +} + +export default function Shortcuts (data: { centerElement?: any; }) { const iconMap: Record = { [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 ( -
- {data.shortcuts?.filter(s => !!s.label).map((s, i) => 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} /> - )} -
+ <> +
+ {shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => 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} /> + )} +
+ {data.centerElement} +
+ {shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => 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} /> + )} +
+ ); } diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 2ee89c6..d4067ee 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -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 }); diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index 7e55506..8636bf1 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -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(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) }); + const [localValue, setLocalValue] = useLocalStorage(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { + deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) + }); return ( @@ -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' && { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 2181b93..31c1d27 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -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; - 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(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 (