diff --git a/frontend/src/composables/useGamepadNavigation.ts b/frontend/src/composables/useGamepadNavigation.ts index 1a21a4b..247741c 100644 --- a/frontend/src/composables/useGamepadNavigation.ts +++ b/frontend/src/composables/useGamepadNavigation.ts @@ -1,8 +1,13 @@ type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu" +type DirectionAction = "up" | "down" | "left" | "right" -const GAMEPAD_AXIS_THRESHOLD = 0.55 -const GAMEPAD_REPEAT_MS = 90 -const GAMEPAD_VERTICAL_REPEAT_MS = 140 +const ANALOG_DEADZONE = 0.25 +const DIGITAL_INITIAL_REPEAT_DELAY_MS = 150 +const DIGITAL_REPEAT_START_MS = 90 +const DIGITAL_REPEAT_MIN_MS = 16 +const DIGITAL_ACCEL_RAMP_MS = 3500 +const ANALOG_REPEAT_MAX_MS = 180 +const ANALOG_REPEAT_MIN_MS = 16 const KEY_BY_ACTION: Partial> = { up: "ArrowUp", @@ -23,33 +28,23 @@ const gamepadPressedState: Record = { menu: false, } -const gamepadLastTriggerAt: Record = { +const rawButtonPressedState: Record = { + 2: false, + 3: false, + 4: false, + 5: false, +} + +const analogLastTriggerAt: Record = { up: 0, down: 0, left: 0, right: 0, - select: 0, - back: 0, - menu: 0, } -const rawButtonLastTriggerAt: Record = { - 2: 0, - 3: 0, - 4: 0, - 5: 0, -} - -function applyRawButtonEvent(button: number, now: number) { - const canTrigger = now - (rawButtonLastTriggerAt[button] ?? 0) >= GAMEPAD_REPEAT_MS - if (!canTrigger) return - rawButtonLastTriggerAt[button] = now - const event = new CustomEvent("mediahive:gamepad-button", { - detail: { button }, - cancelable: true, - }) - window.dispatchEvent(event) -} +let digitalHoldStartedAt = 0 +let digitalLastRepeatAt = 0 +let digitalRepeatCount = 0 let gamepadFrameId: number | null = null let gamepadInstalled = false @@ -66,21 +61,7 @@ function dispatchKey(key: string) { ) } -function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) { - const wasPressed = gamepadPressedState[action] - gamepadPressedState[action] = isPressed - - if (!isPressed) return - - const isVertical = action === "up" || action === "down" - const isHorizontal = action === "left" || action === "right" - const shouldRepeat = isVertical || isHorizontal - const repeatMs = isVertical ? GAMEPAD_VERTICAL_REPEAT_MS : GAMEPAD_REPEAT_MS - const canTrigger = - !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= repeatMs) - if (!canTrigger) return - - gamepadLastTriggerAt[action] = now +function dispatchGamepadAction(action: GamepadAction): void { const actionEvent = new CustomEvent("mediahive:gamepad-action", { detail: { action }, cancelable: true, @@ -94,6 +75,62 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb } } +function dispatchRawButton(button: number): void { + const event = new CustomEvent("mediahive:gamepad-button", { + detail: { button }, + cancelable: true, + }) + window.dispatchEvent(event) +} + +function triggerSinglePressAction(action: GamepadAction, isPressed: boolean): void { + const wasPressed = gamepadPressedState[action] + gamepadPressedState[action] = isPressed + if (isPressed && !wasPressed) { + dispatchGamepadAction(action) + } +} + +function triggerSinglePressRawButton(button: number, isPressed: boolean): void { + const wasPressed = rawButtonPressedState[button] ?? false + rawButtonPressedState[button] = isPressed + if (isPressed && !wasPressed) { + dispatchRawButton(button) + } +} + +function getDigitalRepeatIntervalMs(holdMs: number): number { + const progress = Math.min(Math.max(holdMs, 0), DIGITAL_ACCEL_RAMP_MS) / DIGITAL_ACCEL_RAMP_MS + return Math.round( + DIGITAL_REPEAT_START_MS - (DIGITAL_REPEAT_START_MS - DIGITAL_REPEAT_MIN_MS) * progress, + ) +} + +function getAnalogRepeatIntervalMs(intensity: number): number { + const normalized = Math.min(Math.max(intensity, 0), 1) + return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized) +} + +function normalizeAxisIntensity(rawValue: number): number { + if (rawValue <= ANALOG_DEADZONE) return 0 + return Math.min((rawValue - ANALOG_DEADZONE) / (1 - ANALOG_DEADZONE), 1) +} + +function applyAnalogDirection(action: DirectionAction, intensity: number, now: number): void { + if (intensity <= 0) { + analogLastTriggerAt[action] = 0 + return + } + + const last = analogLastTriggerAt[action] + const repeatMs = getAnalogRepeatIntervalMs(intensity) + + if (last === 0 || now - last >= repeatMs) { + analogLastTriggerAt[action] = now + dispatchGamepadAction(action) + } +} + function resetPressedState() { gamepadPressedState.up = false gamepadPressedState.down = false @@ -102,6 +139,17 @@ function resetPressedState() { gamepadPressedState.select = false gamepadPressedState.back = false gamepadPressedState.menu = false + rawButtonPressedState[2] = false + rawButtonPressedState[3] = false + rawButtonPressedState[4] = false + rawButtonPressedState[5] = false + analogLastTriggerAt.up = 0 + analogLastTriggerAt.down = 0 + analogLastTriggerAt.left = 0 + analogLastTriggerAt.right = 0 + digitalHoldStartedAt = 0 + digitalLastRepeatAt = 0 + digitalRepeatCount = 0 } function pollGamepad() { @@ -110,56 +158,104 @@ function pollGamepad() { const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected)) if (connectedGamepads.length > 0) { - let up = false - let down = false - let left = false - let right = false + let digitalUp = false + let digitalDown = false + let digitalLeft = false + let digitalRight = false + let analogUpIntensity = 0 + let analogDownIntensity = 0 + let analogLeftIntensity = 0 + let analogRightIntensity = 0 let select = false let back = false let menu = false + let raw2 = false + let raw3 = false + let raw4 = false + let raw5 = false for (const gamepad of connectedGamepads) { const axisX = gamepad.axes[0] ?? 0 const axisY = gamepad.axes[1] ?? 0 - up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD - down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD - left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD - right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD + digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed) + digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed) + digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed) + digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed) + + const upIntensity = normalizeAxisIntensity(-axisY) + const downIntensity = normalizeAxisIntensity(axisY) + const leftIntensity = normalizeAxisIntensity(-axisX) + const rightIntensity = normalizeAxisIntensity(axisX) + if (upIntensity > analogUpIntensity) analogUpIntensity = upIntensity + if (downIntensity > analogDownIntensity) analogDownIntensity = downIntensity + if (leftIntensity > analogLeftIntensity) analogLeftIntensity = leftIntensity + if (rightIntensity > analogRightIntensity) analogRightIntensity = rightIntensity // Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3 select = select || Boolean(gamepad.buttons[0]?.pressed) back = back || Boolean(gamepad.buttons[1]?.pressed) - // X/Square = backspace (handled by raw button event) - // Y/Triangle = space (handled by raw button event) menu = menu || Boolean(gamepad.buttons[3]?.pressed) - if (gamepad.buttons[2]?.pressed) { - applyRawButtonEvent(2, now) - } - if (gamepad.buttons[3]?.pressed) { - applyRawButtonEvent(3, now) + raw2 = raw2 || Boolean(gamepad.buttons[2]?.pressed) + raw3 = raw3 || Boolean(gamepad.buttons[3]?.pressed) + raw4 = raw4 || Boolean(gamepad.buttons[4]?.pressed) + raw5 = raw5 || Boolean(gamepad.buttons[5]?.pressed) + } + + triggerSinglePressAction("up", digitalUp) + triggerSinglePressAction("down", digitalDown) + triggerSinglePressAction("left", digitalLeft) + triggerSinglePressAction("right", digitalRight) + triggerSinglePressAction("select", select) + triggerSinglePressAction("back", back) + triggerSinglePressAction("menu", menu) + triggerSinglePressRawButton(2, raw2) + triggerSinglePressRawButton(3, raw3) + triggerSinglePressRawButton(4, raw4) + triggerSinglePressRawButton(5, raw5) + + const hasDigitalRepeatable = + digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5 + + if (!hasDigitalRepeatable) { + digitalHoldStartedAt = 0 + digitalLastRepeatAt = 0 + digitalRepeatCount = 0 + } else { + if (digitalHoldStartedAt === 0) { + digitalHoldStartedAt = now + digitalLastRepeatAt = now + digitalRepeatCount = 0 } - // Shoulder buttons for cursor movement (handled by raw button event) - const lb = Boolean(gamepad.buttons[4]?.pressed) - const rb = Boolean(gamepad.buttons[5]?.pressed) + const holdMs = now - digitalHoldStartedAt + const repeatMs = + digitalRepeatCount === 0 + ? DIGITAL_INITIAL_REPEAT_DELAY_MS + : getDigitalRepeatIntervalMs(holdMs) - if (lb) { - applyRawButtonEvent(4, now) - } - if (rb) { - applyRawButtonEvent(5, now) + if (now - digitalLastRepeatAt >= repeatMs) { + if (digitalUp) dispatchGamepadAction("up") + if (digitalDown) dispatchGamepadAction("down") + if (digitalLeft) dispatchGamepadAction("left") + if (digitalRight) dispatchGamepadAction("right") + if (raw2) dispatchRawButton(2) + if (raw3) dispatchRawButton(3) + if (raw4) dispatchRawButton(4) + if (raw5) dispatchRawButton(5) + + digitalLastRepeatAt = now + digitalRepeatCount += 1 } } - applyGamepadAction("up", up, now) - applyGamepadAction("down", down, now) - applyGamepadAction("left", left, now) - applyGamepadAction("right", right, now) - applyGamepadAction("select", select, now) - applyGamepadAction("back", back, now) - applyGamepadAction("menu", menu, now) + // Analog repeat has no initial delay and speed increases with stick deflection. + // If digital d-pad direction is held, it owns that direction to avoid duplicate repeats. + applyAnalogDirection("up", digitalUp ? 0 : analogUpIntensity, now) + applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now) + applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now) + applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now) } else { resetPressedState() }