diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index e9aaac82..a27b9620 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; +import { useGamepad } from '@/hooks/useGamepad'; import { useTranslation } from '@/hooks/useTranslation'; import { SystemSettings } from '@/types/settings'; import { parseOpenWithFiles } from '@/helpers/openWith'; @@ -50,6 +51,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i const [errorLoading, setErrorLoading] = useState(false); useBookShortcuts({ sideBarBookKey, bookKeys }); + useGamepad(); useEffect(() => { if (isInitiating.current) return; diff --git a/apps/readest-app/src/hooks/useGamepad.ts b/apps/readest-app/src/hooks/useGamepad.ts new file mode 100644 index 00000000..20b91e2a --- /dev/null +++ b/apps/readest-app/src/hooks/useGamepad.ts @@ -0,0 +1,198 @@ +import { useEffect, useRef, useCallback } from 'react'; + +interface ButtonMapping { + key: string; + code: string; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +interface GamepadConfig { + buttonMap?: Record; + axisThreshold?: number; + enabled?: boolean; +} + +const DEFAULT_BUTTON_MAP: Record = { + 0: { key: 'Enter', code: 'Enter' }, // A + 1: { key: 'Escape', code: 'Escape' }, // B + 2: { key: 'x', code: 'KeyX' }, // X + 3: { key: 'y', code: 'KeyY' }, // Y + 4: { key: 'ArrowLeft', code: 'ArrowLeft' }, // LB + 5: { key: 'ArrowRight', code: 'ArrowRight' }, // RB + 6: { key: 'ArrowLeft', code: 'ArrowLeft' }, // LT + 7: { key: 'ArrowRight', code: 'ArrowRight' }, // RT + 8: { key: 'Tab', code: 'Tab' }, // Select/Back + 9: { key: 'Escape', code: 'Escape' }, // Start/Menu + 12: { key: 'ArrowUp', code: 'ArrowUp' }, // D-pad Up + 13: { key: 'ArrowDown', code: 'ArrowDown' }, // D-pad Down + 14: { key: 'ArrowLeft', code: 'ArrowLeft' }, // D-pad Left + 15: { key: 'ArrowRight', code: 'ArrowRight' }, // D-pad Right +}; + +export function useGamepad(config: GamepadConfig = {}) { + const { buttonMap = DEFAULT_BUTTON_MAP, axisThreshold = 0.5, enabled = true } = config; + + const pressedRef = useRef>(new Map()); + const axisStateRef = useRef>(new Map()); + const animationRef = useRef(null); + const connectedRef = useRef(false); + + const dispatchKey = useCallback((mapping: ButtonMapping, type: 'keydown' | 'keyup') => { + const event = new KeyboardEvent(type, { + key: mapping.key, + code: mapping.code, + shiftKey: mapping.shiftKey || false, + ctrlKey: mapping.ctrlKey || false, + altKey: mapping.altKey || false, + metaKey: mapping.metaKey || false, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + }, []); + + const handleAxisAsButton = useCallback( + ( + axisIndex: number, + value: number, + negativeMapping: ButtonMapping | undefined, + positiveMapping: ButtonMapping | undefined, + ) => { + const prevValue = axisStateRef.current.get(axisIndex) || 0; + axisStateRef.current.set(axisIndex, value); + + const wasNegative = prevValue < -axisThreshold; + const wasPositive = prevValue > axisThreshold; + const isNegative = value < -axisThreshold; + const isPositive = value > axisThreshold; + + if (negativeMapping) { + if (isNegative && !wasNegative) { + dispatchKey(negativeMapping, 'keydown'); + } else if (!isNegative && wasNegative) { + dispatchKey(negativeMapping, 'keyup'); + } + } + + if (positiveMapping) { + if (isPositive && !wasPositive) { + dispatchKey(positiveMapping, 'keydown'); + } else if (!isPositive && wasPositive) { + dispatchKey(positiveMapping, 'keyup'); + } + } + }, + [axisThreshold, dispatchKey], + ); + + const pollGamepad = useCallback(() => { + const gamepads = navigator.getGamepads(); + const gamepad = gamepads[0] || gamepads[1] || gamepads[2] || gamepads[3]; + + if (!gamepad) return; + + // Handle buttons + for (const [indexStr, mapping] of Object.entries(buttonMap)) { + const index = Number(indexStr); + const button = gamepad.buttons[index]; + if (!button) continue; + + const isPressed = button.pressed; + const wasPressed = pressedRef.current.get(index) || false; + + if (isPressed && !wasPressed) { + dispatchKey(mapping, 'keydown'); + } else if (!isPressed && wasPressed) { + dispatchKey(mapping, 'keyup'); + } + + pressedRef.current.set(index, isPressed); + } + + // Handle left and right sticks as arrow keys + if (gamepad.axes.length >= 2) { + handleAxisAsButton( + 0, + gamepad.axes[0]!, + { key: 'ArrowLeft', code: 'ArrowLeft' }, + { key: 'ArrowRight', code: 'ArrowRight' }, + ); + handleAxisAsButton( + 1, + gamepad.axes[1]!, + { key: 'ArrowUp', code: 'ArrowUp' }, + { key: 'ArrowDown', code: 'ArrowDown' }, + ); + } + if (gamepad.axes.length >= 4) { + handleAxisAsButton( + 2, + gamepad.axes[2]!, + { key: 'ArrowLeft', code: 'ArrowLeft' }, + { key: 'ArrowRight', code: 'ArrowRight' }, + ); + handleAxisAsButton( + 3, + gamepad.axes[3]!, + { key: 'ArrowUp', code: 'ArrowUp' }, + { key: 'ArrowDown', code: 'ArrowDown' }, + ); + } + }, [buttonMap, dispatchKey, handleAxisAsButton]); + + useEffect(() => { + if (!enabled) return; + + const startPolling = () => { + if (connectedRef.current) return; + connectedRef.current = true; + + const poll = () => { + pollGamepad(); + animationRef.current = requestAnimationFrame(poll); + }; + poll(); + }; + + const stopPolling = () => { + connectedRef.current = false; + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + pressedRef.current.clear(); + axisStateRef.current.clear(); + }; + + const onConnect = (e: GamepadEvent) => { + console.log('Gamepad connected:', e.gamepad.id); + startPolling(); + }; + + const onDisconnect = (e: GamepadEvent) => { + console.log('Gamepad disconnected:', e.gamepad.id); + const gamepads = navigator.getGamepads(); + if (!gamepads.some((g) => g?.connected)) { + stopPolling(); + } + }; + + window.addEventListener('gamepadconnected', onConnect); + window.addEventListener('gamepaddisconnected', onDisconnect); + + // Check if gamepad already connected + const gamepads = navigator.getGamepads(); + if (gamepads.some((g) => g?.connected)) { + startPolling(); + } + + return () => { + window.removeEventListener('gamepadconnected', onConnect); + window.removeEventListener('gamepaddisconnected', onDisconnect); + stopPolling(); + }; + }, [enabled, pollGamepad]); +}