Add Steam Input support for Big Picture Mode

Introduces a Steam Input bridge using steamworks.js, enabling native controller support in Big Picture Mode and on Steam Deck. Adds a new steam-input-manager.js module, integrates IPC handlers in main.js, exposes a steamInputAPI in preload.js, and updates bigpicture.js to use Steam Input when available with fallback to legacy Gamepad API. Updates dependencies and scripts in package.json for Steam Deck and Big Picture profiles.
This commit is contained in:
2025-12-30 17:52:17 +13:00
parent 55858f13ac
commit 8994b9b2d3
6 changed files with 601 additions and 142 deletions
+199 -132
View File
@@ -144,8 +144,13 @@ const state = {
// Gamepad
gamepadConnected: false,
gamepadIndex: null,
lastInput: { x: 0, y: 0 },
lastInput: {},
inputRepeatTimer: null,
legacyGamepadEnabled: false,
useSteamInput: false,
steamInputStatus: null,
steamInputUnsubscribe: null,
steamInputCleanupBound: false,
// Virtual cursor for webview
cursorEnabled: false,
@@ -648,12 +653,63 @@ function goForward() {
// GAMEPAD SUPPORT
// =============================================================================
function initGamepadSupport() {
function processDirectionalInput(direction, isPressed) {
const now = Date.now();
if (isPressed && !state.lastInput[direction]) {
navigateFocus(direction);
state.lastInput[direction] = now;
} else if (!isPressed) {
state.lastInput[direction] = 0;
}
}
function processButtonInput(key, pressed, handler) {
if (pressed && !state.lastInput[key]) {
handler();
state.lastInput[key] = true;
} else if (!pressed) {
state.lastInput[key] = false;
}
}
function handleCursorSpeedToggle() {
state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15);
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
}
async function initGamepadSupport() {
if (window.steamInputAPI) {
try {
const status = await window.steamInputAPI.start();
state.steamInputStatus = status;
if (status?.enabled) {
state.useSteamInput = true;
if (!state.steamInputCleanupBound) {
window.addEventListener('beforeunload', cleanupSteamInput);
state.steamInputCleanupBound = true;
}
state.steamInputUnsubscribe = window.steamInputAPI.onState(handleSteamInputState);
console.log('[BigPicture] Steam Input enabled via steamworks.js');
return;
}
console.log('[BigPicture] Steam Input unavailable, falling back:', status?.reason);
} catch (err) {
console.warn('[BigPicture] Failed to initialize Steam Input:', err);
}
}
initLegacyGamepadSupport();
}
function initLegacyGamepadSupport() {
if (state.legacyGamepadEnabled) return;
if (!navigator.getGamepads) {
console.warn('[BigPicture] Gamepad API not available in this environment');
return;
}
state.legacyGamepadEnabled = true;
// Note: On Linux (and some controllers like handheld integrated gamepads),
// the `gamepadconnected` event may not fire until the first button press,
// or at all. We rely on continuous polling for robustness.
@@ -731,6 +787,10 @@ function refreshActiveGamepad(isInitial = false) {
}
function pollGamepad() {
if (!state.legacyGamepadEnabled || state.useSteamInput) {
requestAnimationFrame(pollGamepad);
return;
}
const { active } = refreshActiveGamepad(false);
if (active) {
handleGamepadInput(active);
@@ -740,9 +800,11 @@ function pollGamepad() {
}
function handleGamepadInput(gamepad) {
if (state.useSteamInput) return;
// D-pad and left stick for navigation
const leftX = gamepad.axes[0];
const leftY = gamepad.axes[1];
const leftX = gamepad.axes[0] || 0;
const leftY = gamepad.axes[1] || 0;
// D-pad buttons (indices may vary by controller)
const dpadUp = gamepad.buttons[12]?.pressed;
@@ -756,122 +818,49 @@ function handleGamepadInput(gamepad) {
const stickLeft = leftX < -CONFIG.STICK_DEADZONE;
const stickRight = leftX > CONFIG.STICK_DEADZONE;
// When cursor is enabled (viewing a webpage), only D-Pad navigates sidebar
// Left stick is ignored for UI navigation in webview mode
const inWebviewMode = state.cursorEnabled && state.currentWebview;
// Combine inputs - but only use D-Pad when in webview mode
const up = inWebviewMode ? dpadUp : (dpadUp || stickUp);
const down = inWebviewMode ? dpadDown : (dpadDown || stickDown);
const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft);
const right = inWebviewMode ? dpadRight : (dpadRight || stickRight);
// Navigation with repeat prevention
const now = Date.now();
if (up && !state.lastInput.up) {
navigateFocus('up');
state.lastInput.up = now;
} else if (!up) {
state.lastInput.up = 0;
}
if (down && !state.lastInput.down) {
navigateFocus('down');
state.lastInput.down = now;
} else if (!down) {
state.lastInput.down = 0;
}
if (left && !state.lastInput.left) {
navigateFocus('left');
state.lastInput.left = now;
} else if (!left) {
state.lastInput.left = 0;
}
if (right && !state.lastInput.right) {
navigateFocus('right');
state.lastInput.right = now;
} else if (!right) {
state.lastInput.right = 0;
}
// A button (usually index 0) - Always select/activate focused menu item
if (gamepad.buttons[0]?.pressed && !state.lastInput.a) {
activateFocused();
state.lastInput.a = true;
} else if (!gamepad.buttons[0]?.pressed) {
state.lastInput.a = false;
}
// B button (usually index 1) - Back/Close OSK
if (gamepad.buttons[1]?.pressed && !state.lastInput.b) {
goBack();
state.lastInput.b = true;
} else if (!gamepad.buttons[1]?.pressed) {
state.lastInput.b = false;
}
// X button (usually index 2) - Backspace when OSK is open
if (gamepad.buttons[2]?.pressed && !state.lastInput.x) {
if (state.oskVisible) {
backspaceOSK();
}
state.lastInput.x = true;
} else if (!gamepad.buttons[2]?.pressed) {
state.lastInput.x = false;
}
// Y button (usually index 3) - Space when OSK open, otherwise open search
if (gamepad.buttons[3]?.pressed && !state.lastInput.y) {
processDirectionalInput('up', up);
processDirectionalInput('down', down);
processDirectionalInput('left', left);
processDirectionalInput('right', right);
processButtonInput('a', gamepad.buttons[0]?.pressed, activateFocused);
processButtonInput('b', gamepad.buttons[1]?.pressed, () => goBack());
processButtonInput('x', gamepad.buttons[2]?.pressed, () => {
if (state.oskVisible) backspaceOSK();
});
processButtonInput('y', gamepad.buttons[3]?.pressed, () => {
if (state.oskVisible) {
appendToOSK(' ');
} else {
openOSK('search');
}
state.lastInput.y = true;
} else if (!gamepad.buttons[3]?.pressed) {
state.lastInput.y = false;
}
// LB button (usually index 4) - Go back in webview / clear OSK
if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) {
});
processButtonInput('lb', gamepad.buttons[4]?.pressed, () => {
if (state.oskVisible) {
clearOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goBack();
}
state.lastInput.lb = true;
} else if (!gamepad.buttons[4]?.pressed) {
state.lastInput.lb = false;
}
// RB button (usually index 5) - Go forward in webview / submit OSK
if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) {
});
processButtonInput('rb', gamepad.buttons[5]?.pressed, () => {
if (state.oskVisible) {
submitOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goForward();
}
state.lastInput.rb = true;
} else if (!gamepad.buttons[5]?.pressed) {
state.lastInput.rb = false;
}
// Back/Select button (usually index 8) - Toggle sidebar when in webview
if (gamepad.buttons[8]?.pressed && !state.lastInput.select) {
});
processButtonInput('select', gamepad.buttons[8]?.pressed, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
}
state.lastInput.select = true;
} else if (!gamepad.buttons[8]?.pressed) {
state.lastInput.select = false;
}
// Start button (usually index 9) - Menu / Toggle sidebar when viewing webpage
if (gamepad.buttons[9]?.pressed && !state.lastInput.start) {
// If viewing a webpage, toggle sidebar instead of going to settings
});
processButtonInput('start', gamepad.buttons[9]?.pressed, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
} else if (state.currentSection !== 'settings') {
@@ -879,60 +868,138 @@ function handleGamepadInput(gamepad) {
} else {
switchSection('home');
}
state.lastInput.start = true;
} else if (!gamepad.buttons[9]?.pressed) {
state.lastInput.start = false;
}
// Virtual cursor handling when webview is active
});
if (state.cursorEnabled && state.currentWebview) {
// Right stick for cursor movement
const rightX = gamepad.axes[2] || 0;
const rightY = gamepad.axes[3] || 0;
// Apply deadzone
const deadzone = 0.15;
const moveX = Math.abs(rightX) > deadzone ? rightX : 0;
const moveY = Math.abs(rightY) > deadzone ? rightY : 0;
if (moveX !== 0 || moveY !== 0) {
moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed);
}
// Left stick for scrolling in webview mode
const scrollDeadzone = 0.25;
const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0;
const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0;
if (scrollX !== 0 || scrollY !== 0) {
scrollWebview(scrollY * 20, scrollX * 20);
}
// Right trigger (index 7) - Left click
if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) {
virtualClick();
state.lastInput.rt = true;
} else if (!gamepad.buttons[7]?.pressed) {
state.lastInput.rt = false;
processButtonInput('rt', gamepad.buttons[7]?.pressed, () => virtualClick());
processButtonInput('lt', gamepad.buttons[6]?.pressed, () => virtualClick(true));
processButtonInput('rs', gamepad.buttons[11]?.pressed, handleCursorSpeedToggle);
}
}
function handleSteamInputState(payload) {
if (!payload || !payload.connected || !payload.controller) {
state.useSteamInput = false;
if (!state.legacyGamepadEnabled) {
initLegacyGamepadSupport();
}
// Left trigger (index 6) - Right click
if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) {
virtualClick(true);
state.lastInput.lt = true;
} else if (!gamepad.buttons[6]?.pressed) {
state.lastInput.lt = false;
return;
}
state.gamepadConnected = true;
state.useSteamInput = true;
const controller = payload.controller;
const nav = controller.nav || {};
const buttons = controller.buttons || {};
const analog = controller.analog || {};
const triggers = analog.triggers || { left: 0, right: 0 };
processDirectionalInput('up', !!nav.up);
processDirectionalInput('down', !!nav.down);
processDirectionalInput('left', !!nav.left);
processDirectionalInput('right', !!nav.right);
processButtonInput('a', !!buttons.confirm, activateFocused);
processButtonInput('b', !!buttons.back, () => goBack());
processButtonInput('x', !!buttons.oskBackspace, () => {
if (state.oskVisible) backspaceOSK();
});
processButtonInput('y', !!buttons.oskSpace, () => {
if (state.oskVisible) {
appendToOSK(' ');
} else {
openOSK('search');
}
// Right stick click (index 11) - Toggle cursor speed
if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) {
state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15);
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
state.lastInput.rs = true;
} else if (!gamepad.buttons[11]?.pressed) {
state.lastInput.rs = false;
});
const shoulderLeftPressed = !!buttons.shoulderLeft || triggers.left > 0.6;
const shoulderRightPressed = !!buttons.shoulderRight || triggers.right > 0.6;
processButtonInput('lb', shoulderLeftPressed, () => {
if (state.oskVisible) {
clearOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goBack();
}
});
processButtonInput('rb', shoulderRightPressed, () => {
if (state.oskVisible) {
submitOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goForward();
}
});
processButtonInput('select', !!buttons.toggleSidebar, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
}
});
processButtonInput('start', !!buttons.menu, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
} else if (state.currentSection !== 'settings') {
switchSection('settings');
} else {
switchSection('home');
}
});
processButtonInput('osk', !!buttons.showOsk, () => {
if (state.currentWebview) {
openOSKForWebview();
} else {
openOSK('search');
}
});
if (state.cursorEnabled && state.currentWebview) {
const cursorVec = analog.cursor || { x: 0, y: 0 };
const scrollVec = analog.scroll || { x: 0, y: 0 };
if (Math.abs(cursorVec.x) > 0.05 || Math.abs(cursorVec.y) > 0.05) {
moveCursor(cursorVec.x * state.cursorSpeed, cursorVec.y * state.cursorSpeed);
}
if (Math.abs(scrollVec.x) > 0.05 || Math.abs(scrollVec.y) > 0.05) {
scrollWebview(scrollVec.y * 40, scrollVec.x * 40);
}
const primaryPressed = !!buttons.cursorPrimary || triggers.right > 0.6;
const secondaryPressed = !!buttons.cursorSecondary || triggers.left > 0.6;
processButtonInput('rt', primaryPressed, () => virtualClick());
processButtonInput('lt', secondaryPressed, () => virtualClick(true));
processButtonInput('rs', !!buttons.cursorSpeed, handleCursorSpeedToggle);
}
}
function cleanupSteamInput() {
if (state.steamInputUnsubscribe) {
try { state.steamInputUnsubscribe(); } catch {}
state.steamInputUnsubscribe = null;
}
if (window.steamInputAPI) {
try { window.steamInputAPI.stop(); } catch (err) {
console.warn('[BigPicture] Failed to stop Steam Input bridge:', err);
}
}
state.useSteamInput = false;
}
// =============================================================================