import type { ControllerAxisBinding, ControllerButtonBinding, ControllerDeviceInfo, ControllerRuntimeSnapshot, ControllerTriggerInputMode, ResolvedControllerConfig, } from '../../types'; type ControllerButtonState = { value: number; pressed?: boolean; touched?: boolean; }; type GamepadLike = { id: string; index: number; connected: boolean; mapping: string; axes: readonly number[]; buttons: readonly ControllerButtonState[]; }; type GamepadControllerOptions = { getGamepads: () => Array; getConfig: () => ResolvedControllerConfig; getKeyboardModeEnabled: () => boolean; getLookupWindowOpen: () => boolean; getInteractionBlocked: () => boolean; toggleKeyboardMode: () => void; toggleLookup: () => void; closeLookup: () => void; moveSelection: (delta: -1 | 1) => void; mineCard: () => void; quitMpv: () => void; previousAudio: () => void; nextAudio: () => void; playCurrentAudio: () => void; toggleMpvPause: () => void; scrollPopup: (deltaPixels: number) => void; jumpPopup: (deltaPixels: number) => void; onState: (state: ControllerRuntimeSnapshot) => void; }; type HoldState = { repeatStarted: boolean; direction: -1 | 1 | null; lastFireAt: number; initialFired: boolean; }; const DEFAULT_BUTTON_INDEX_BY_BINDING: Record, number> = { select: 8, buttonSouth: 0, buttonEast: 1, buttonWest: 2, buttonNorth: 3, leftShoulder: 4, rightShoulder: 5, leftStickPress: 9, rightStickPress: 10, leftTrigger: 6, rightTrigger: 7, }; const AXIS_INDEX_BY_BINDING: Record = { leftStickX: 0, leftStickY: 1, rightStickX: 3, rightStickY: 4, }; const DPAD_BUTTON_INDEX = { up: 12, down: 13, left: 14, right: 15, } as const; const DPAD_AXIS_INDEX = { horizontal: 6, vertical: 7, } as const; function isTriggerBinding(binding: ControllerButtonBinding): boolean { return binding === 'leftTrigger' || binding === 'rightTrigger'; } function resolveButtonIndex( config: ResolvedControllerConfig, binding: ControllerButtonBinding, ): number { if (binding === 'none') { return -1; } return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding]; } function normalizeButtonState( gamepad: GamepadLike, config: ResolvedControllerConfig, binding: ControllerButtonBinding, triggerInputMode: ControllerTriggerInputMode, triggerDeadzone: number, ): boolean { if (binding === 'none') { return false; } const button = gamepad.buttons[resolveButtonIndex(config, binding)]; if (isTriggerBinding(binding)) { return normalizeTriggerState(button, triggerInputMode, triggerDeadzone); } return normalizeRawButtonState(button, triggerDeadzone); } function normalizeRawButtonState( button: ControllerButtonState | undefined, triggerDeadzone: number, ): boolean { if (!button) return false; return Boolean(button.pressed) || button.value >= triggerDeadzone; } function normalizeTriggerState( button: ControllerButtonState | undefined, mode: ControllerTriggerInputMode, triggerDeadzone: number, ): boolean { if (!button) return false; if (mode === 'digital') { return Boolean(button.pressed); } if (mode === 'analog') { return button.value >= triggerDeadzone; } return Boolean(button.pressed) || button.value >= triggerDeadzone; } function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number { return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0; } function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number { const value = gamepad.axes[axisIndex]; return typeof value === 'number' && Number.isFinite(value) ? value : 0; } function resolveDpadValue( gamepad: GamepadLike, negativeIndex: number, positiveIndex: number, triggerDeadzone: number, ): number { const negative = gamepad.buttons[negativeIndex]; const positive = gamepad.buttons[positiveIndex]; return ( (normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) - (normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0) ); } function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number { const value = resolveGamepadAxis(gamepad, axisIndex); if (Math.abs(value) < 0.5) { return 0; } return Math.sign(value); } function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number { const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal); if (axisValue !== 0) { return axisValue; } return resolveDpadValue( gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone, ); } function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number { const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical); if (axisValue !== 0) { return axisValue; } return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone); } function resolveConnectedGamepads(gamepads: Array): GamepadLike[] { return gamepads .filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected)) .sort((left, right) => left.index - right.index); } function createHoldState(): HoldState { return { repeatStarted: false, direction: null, lastFireAt: 0, initialFired: false, }; } function shouldFireHeldAction( state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number, ): boolean { if (!state.initialFired) { state.initialFired = true; state.lastFireAt = now; return true; } const elapsed = now - state.lastFireAt; const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs; if (elapsed < threshold) { return false; } state.repeatStarted = true; state.lastFireAt = now; return true; } function resetHeldAction(state: HoldState): void { state.repeatStarted = false; state.direction = null; state.lastFireAt = 0; state.initialFired = false; } function syncHeldActionBlocked( state: HoldState, value: number, now: number, activationThreshold: number, ): void { if (Math.abs(value) < activationThreshold) { resetHeldAction(state); return; } const direction = value > 0 ? 1 : -1; state.repeatStarted = false; state.direction = direction; state.lastFireAt = now; state.initialFired = true; } export function createGamepadController(options: GamepadControllerOptions) { let previousButtons = new Map(); let selectionHold = createHoldState(); let jumpHold = createHoldState(); let activeGamepadId: string | null = null; let lastPollAt: number | null = null; function getConnectedGamepads(): GamepadLike[] { return resolveConnectedGamepads(options.getGamepads()); } function resolveActiveGamepad( gamepads: GamepadLike[], config: ResolvedControllerConfig, ): GamepadLike | null { if (gamepads.length === 0) return null; if (config.preferredGamepadId.trim().length > 0) { const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId); if (preferred) { return preferred; } } return gamepads[0] ?? null; } function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void { activeGamepadId = activeGamepad?.id ?? null; options.onState({ connectedGamepads: gamepads.map((gamepad) => ({ id: gamepad.id, index: gamepad.index, mapping: gamepad.mapping, connected: gamepad.connected, })) satisfies ControllerDeviceInfo[], activeGamepadId, rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [], rawButtons: activeGamepad?.buttons ? activeGamepad.buttons.map((button) => ({ value: button.value, pressed: Boolean(button.pressed), touched: button.touched, })) : [], }); } function handleButtonEdge( binding: ControllerButtonBinding, isPressed: boolean, action: () => void, ): void { if (binding === 'none') { return; } const wasPressed = previousButtons.get(binding) ?? false; previousButtons.set(binding, isPressed); if (!wasPressed && isPressed) { action(); } } function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void { const activationThreshold = Math.max(config.stickDeadzone, 0.55); if (Math.abs(value) < activationThreshold) { resetHeldAction(selectionHold); return; } const direction = value > 0 ? 1 : -1; if (selectionHold.direction !== direction) { resetHeldAction(selectionHold); selectionHold.direction = direction; } if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) { options.moveSelection(direction); } } function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void { const activationThreshold = Math.max(config.stickDeadzone, 0.55); if (Math.abs(value) < activationThreshold) { resetHeldAction(jumpHold); return; } const direction = value > 0 ? 1 : -1; if (jumpHold.direction !== direction) { resetHeldAction(jumpHold); jumpHold.direction = direction; } if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) { options.jumpPopup(direction * config.horizontalJumpPixels); } } function syncBlockedInteractionState( activeGamepad: GamepadLike, config: ResolvedControllerConfig, now: number, ): void { const buttonBindings = new Set([ config.bindings.toggleKeyboardOnlyMode, config.bindings.toggleLookup, config.bindings.closeLookup, config.bindings.mineCard, config.bindings.quitMpv, config.bindings.previousAudio, config.bindings.nextAudio, config.bindings.playCurrentAudio, config.bindings.toggleMpvPause, ]); for (const binding of buttonBindings) { if (binding === 'none') continue; previousButtons.set( binding, normalizeButtonState( activeGamepad, config, binding, config.triggerInputMode, config.triggerDeadzone, ), ); } const selectionValue = (() => { const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { return axisValue; } return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); })(); syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55)); if (options.getLookupWindowOpen()) { syncHeldActionBlocked( jumpHold, resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), now, Math.max(config.stickDeadzone, 0.55), ); } else { resetHeldAction(jumpHold); } } function poll(now: number): void { const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0); lastPollAt = now; const config = options.getConfig(); const connectedGamepads = getConnectedGamepads(); const activeGamepad = resolveActiveGamepad(connectedGamepads, config); publishState(connectedGamepads, activeGamepad); if (!activeGamepad) { previousButtons = new Map(); resetHeldAction(selectionHold); resetHeldAction(jumpHold); lastPollAt = null; return; } const interactionAllowed = config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); if (config.enabled) { handleButtonEdge( config.bindings.toggleKeyboardOnlyMode, normalizeButtonState( activeGamepad, config, config.bindings.toggleKeyboardOnlyMode, config.triggerInputMode, config.triggerDeadzone, ), options.toggleKeyboardMode, ); } if (!interactionAllowed) { syncBlockedInteractionState(activeGamepad, config, now); return; } handleButtonEdge( config.bindings.toggleLookup, normalizeButtonState( activeGamepad, config, config.bindings.toggleLookup, config.triggerInputMode, config.triggerDeadzone, ), options.toggleLookup, ); handleButtonEdge( config.bindings.closeLookup, normalizeButtonState( activeGamepad, config, config.bindings.closeLookup, config.triggerInputMode, config.triggerDeadzone, ), options.closeLookup, ); handleButtonEdge( config.bindings.mineCard, normalizeButtonState( activeGamepad, config, config.bindings.mineCard, config.triggerInputMode, config.triggerDeadzone, ), options.mineCard, ); handleButtonEdge( config.bindings.quitMpv, normalizeButtonState( activeGamepad, config, config.bindings.quitMpv, config.triggerInputMode, config.triggerDeadzone, ), options.quitMpv, ); if (options.getLookupWindowOpen()) { handleButtonEdge( config.bindings.previousAudio, normalizeButtonState( activeGamepad, config, config.bindings.previousAudio, config.triggerInputMode, config.triggerDeadzone, ), options.previousAudio, ); handleButtonEdge( config.bindings.nextAudio, normalizeButtonState( activeGamepad, config, config.bindings.nextAudio, config.triggerInputMode, config.triggerDeadzone, ), options.nextAudio, ); handleButtonEdge( config.bindings.playCurrentAudio, normalizeButtonState( activeGamepad, config, config.bindings.playCurrentAudio, config.triggerInputMode, config.triggerDeadzone, ), options.playCurrentAudio, ); const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone); const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical); if (elapsedMs > 0) { if (Math.abs(primaryScroll) >= config.stickDeadzone) { options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000); } if (dpadVertical !== 0) { options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000); } } handleJumpAxis( resolveAxisValue(activeGamepad, config.bindings.rightStickVertical), now, config, ); } else { resetHeldAction(jumpHold); } handleButtonEdge( config.bindings.toggleMpvPause, normalizeButtonState( activeGamepad, config, config.bindings.toggleMpvPause, config.triggerInputMode, config.triggerDeadzone, ), options.toggleMpvPause, ); handleSelectionAxis( (() => { const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal); if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) { return axisValue; } return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone); })(), now, config, ); } return { poll, getActiveGamepadId: (): string | null => activeGamepadId, }; }