feat: initial romm web ui architecture, bento grids, and spatial gamepad bindings

This commit is contained in:
roormonger
2026-03-23 15:31:41 -04:00
commit 9e8f148a10
40 changed files with 9935 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useFocusable, UseFocusableConfig } from '@noriginmedia/norigin-spatial-navigation';
import { useInputMode } from '../context/InputModeContext';
export const useFocusableAutoScroll = (config?: UseFocusableConfig) => {
const result = useFocusable(config);
const { mode } = useInputMode();
useEffect(() => {
if (mode === 'gamepad' && result.focused && result.ref.current) {
result.ref.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}
}, [result.focused, mode]);
return {
...result,
focused: mode === 'gamepad' ? result.focused : false
};
};

110
src/hooks/useGamepad.ts Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react';
import { navigateByDirection } from '@noriginmedia/norigin-spatial-navigation';
import { useInputMode } from '../context/InputModeContext';
const BUTTON_A = 0;
const BUTTON_B = 1;
const BUTTON_DPAD_UP = 12;
const BUTTON_DPAD_DOWN = 13;
const BUTTON_DPAD_LEFT = 14;
const BUTTON_DPAD_RIGHT = 15;
const AXIS_THRESHOLD = 0.5;
const INITIAL_DELAY_MS = 400;
const REPEAT_RATE_MS = 100;
export const useGamepad = () => {
const requestRef = useRef<number>();
const lastState = useRef<Record<string, boolean>>({});
const lastFireTime = useRef<Record<string, number>>({});
const { setMode } = useInputMode();
const handleInput = (id: string, isPressed: boolean, action: () => void, repeatable = true) => {
const now = performance.now();
const wasPressed = lastState.current[id];
if (isPressed) {
setMode('gamepad');
if (!wasPressed) {
// Initial press isolated
action();
lastFireTime.current[id] = now;
lastState.current[id] = true;
} else if (repeatable) {
// Holding press calculates repeat boundaries natively
const holdTime = now - lastFireTime.current[id];
if (holdTime > INITIAL_DELAY_MS) {
action();
// Reset tracker mathematically for consecutive rapid fires
lastFireTime.current[id] = now - INITIAL_DELAY_MS + REPEAT_RATE_MS;
}
}
} else {
if (wasPressed) {
lastFireTime.current[id] = 0;
lastState.current[id] = false;
}
}
};
const dispatchEnter = () => {
const eventParams = { bubbles: true, cancelable: true };
const keydown = new window.KeyboardEvent('keydown', eventParams);
Object.defineProperty(keydown, 'keyCode', { get: () => 13 });
Object.defineProperty(keydown, 'key', { get: () => 'Enter' });
document.dispatchEvent(keydown);
window.dispatchEvent(keydown);
};
const dispatchEscape = () => {
const eventParams = { bubbles: true, cancelable: true };
const keydown = new window.KeyboardEvent('keydown', eventParams);
Object.defineProperty(keydown, 'keyCode', { get: () => 27 });
Object.defineProperty(keydown, 'key', { get: () => 'Escape' });
document.dispatchEvent(keydown);
window.dispatchEvent(keydown);
};
const checkGamepad = () => {
try {
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
// Multiplex all gamepads to defeat Ghost Virtual Devices hogs natively occupying slot 0
for (let i = 0; i < gamepads.length; i++) {
const gp = gamepads[i];
if (!gp) continue;
const up = gp.buttons[BUTTON_DPAD_UP]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] < -AXIS_THRESHOLD);
const down = gp.buttons[BUTTON_DPAD_DOWN]?.pressed || (gp.axes[1] !== undefined && gp.axes[1] > AXIS_THRESHOLD);
const left = gp.buttons[BUTTON_DPAD_LEFT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] < -AXIS_THRESHOLD);
const right = gp.buttons[BUTTON_DPAD_RIGHT]?.pressed || (gp.axes[0] !== undefined && gp.axes[0] > AXIS_THRESHOLD);
const enter = gp.buttons[BUTTON_A]?.pressed;
const back = gp.buttons[BUTTON_B]?.pressed;
handleInput(`gp${i}_up`, up, () => navigateByDirection('up', {}));
handleInput(`gp${i}_down`, down, () => navigateByDirection('down', {}));
handleInput(`gp${i}_left`, left, () => navigateByDirection('left', {}));
handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {}));
handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false);
handleInput(`gp${i}_back`, back, () => dispatchEscape(), false);
}
} catch (e) {
console.error("Gamepad Polling Error", e);
}
requestRef.current = requestAnimationFrame(checkGamepad);
};
useEffect(() => {
const onConnect = () => console.log("Gamepad Connected Native Callback!");
const onDisconnect = () => console.log("Gamepad Disconnected Native Callback!");
window.addEventListener("gamepadconnected", onConnect);
window.addEventListener("gamepaddisconnected", onDisconnect);
requestRef.current = requestAnimationFrame(checkGamepad);
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
window.removeEventListener("gamepadconnected", onConnect);
window.removeEventListener("gamepaddisconnected", onDisconnect);
};
}, []);
};