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:
@@ -9,12 +9,14 @@ const PerformanceMonitor = require('./performance-monitor');
|
|||||||
const GPUFallback = require('./gpu-fallback');
|
const GPUFallback = require('./gpu-fallback');
|
||||||
const GPUConfig = require('./gpu-config');
|
const GPUConfig = require('./gpu-config');
|
||||||
const PluginManager = require('./plugin-manager');
|
const PluginManager = require('./plugin-manager');
|
||||||
|
const SteamInputManager = require('./steam-input-manager');
|
||||||
|
|
||||||
// Initialize performance monitoring and GPU management
|
// Initialize performance monitoring and GPU management
|
||||||
const perfMonitor = new PerformanceMonitor();
|
const perfMonitor = new PerformanceMonitor();
|
||||||
const gpuFallback = new GPUFallback();
|
const gpuFallback = new GPUFallback();
|
||||||
const gpuConfig = new GPUConfig();
|
const gpuConfig = new GPUConfig();
|
||||||
const pluginManager = new PluginManager();
|
const pluginManager = new PluginManager();
|
||||||
|
const steamInputManager = new SteamInputManager();
|
||||||
|
|
||||||
// Try to enable WebAuthn/platform authenticator features early.
|
// Try to enable WebAuthn/platform authenticator features early.
|
||||||
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
||||||
@@ -263,6 +265,19 @@ ipcMain.on('exit-bigpicture', () => {
|
|||||||
exitBigPictureMode();
|
exitBigPictureMode();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Steam Input bridge
|
||||||
|
ipcMain.handle('steam-input-start', (event) => {
|
||||||
|
return steamInputManager.subscribe(event.sender);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('steam-input-stop', (event) => {
|
||||||
|
steamInputManager.unsubscribe(event.sender);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('steam-input-status', () => {
|
||||||
|
return steamInputManager.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
// IPC handler for sending mouse input events to webviews (used by Big Picture Mode)
|
// IPC handler for sending mouse input events to webviews (used by Big Picture Mode)
|
||||||
ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => {
|
ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => {
|
||||||
try {
|
try {
|
||||||
@@ -676,6 +691,14 @@ app.on('window-all-closed', () => {
|
|||||||
if (process.platform !== 'darwin') app.quit();
|
if (process.platform !== 'darwin') app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
try {
|
||||||
|
steamInputManager?.dispose?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] dispose failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ipcMain handlers
|
// ipcMain handlers
|
||||||
|
|
||||||
// --- Auto-Update IPC handlers ---
|
// --- Auto-Update IPC handlers ---
|
||||||
|
|||||||
Generated
+42
-5
@@ -1,20 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.0.0",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.0.0",
|
"version": "1.3.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"marked": "^12.0.2"
|
"marked": "^12.0.2",
|
||||||
|
"steamworks.js": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^23.0.0",
|
"electron-builder": "^23.0.0",
|
||||||
"electron-nightly": "^39.0.0-nightly.20250811"
|
"electron-nightly": "^39.0.0-nightly.20250811"
|
||||||
@@ -118,6 +120,13 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@malept/cross-spawn-promise": {
|
"node_modules/@malept/cross-spawn-promise": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
|
||||||
@@ -313,7 +322,6 @@
|
|||||||
"version": "22.16.3",
|
"version": "22.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
|
||||||
"integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
|
"integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -1097,6 +1105,24 @@
|
|||||||
"buffer": "^5.1.0"
|
"buffer": "^5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3079,6 +3105,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/steamworks.js": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/steamworks.js/-/steamworks.js-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5TTRs7ucCRql4IA/kYUIQYeghTsXqf3rAm81sC22RDId264LQYqQjuaMEUSqL60I5LdULiGu0W2/A+ZDcKBKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -3254,7 +3292,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
|
|||||||
+15
-5
@@ -5,22 +5,26 @@
|
|||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"start:dev": "electron . --no-sandbox --disable-gpu",
|
"start:steam-deck": "NEBULA_PROFILE=steam-deck electron .",
|
||||||
"start:linux": "electron . --no-sandbox",
|
"start:big-picture": "NEBULA_PROFILE=big-picture electron .",
|
||||||
"dist": "electron-builder",
|
"dist": "electron-builder",
|
||||||
|
"dist:steam-deck": "set NEBULA_PROFILE=steam-deck && electron-builder",
|
||||||
|
"dist:big-picture": "set NEBULA_PROFILE=big-picture && electron-builder",
|
||||||
"run": "electron ."
|
"run": "electron ."
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "A lightweight, privacy-focused browser with controller-friendly Big Picture Mode for gaming and Steam Deck",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"marked": "^12.0.2"
|
"marked": "^12.0.2",
|
||||||
|
"steamworks.js": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^23.0.0",
|
"electron-builder": "^23.0.0",
|
||||||
"electron-nightly": "^39.0.0-nightly.20250811"
|
"electron-nightly": "^39.0.0-nightly.20250811"
|
||||||
@@ -42,7 +46,13 @@
|
|||||||
"icon": "assets/images/Logos/Nebula-Favicon.ico"
|
"icon": "assets/images/Logos/Nebula-Favicon.ico"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "assets/images/Logos/Nebula-Favicon.png"
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"tar.gz"
|
||||||
|
],
|
||||||
|
"icon": "assets/images/Logos/Nebula-Favicon.png",
|
||||||
|
"category": "Utility",
|
||||||
|
"maintainer": "NebulaBrowser Contributors"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -160,6 +160,18 @@ contextBridge.exposeInMainWorld('bigPictureAPI', {
|
|||||||
ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent })
|
ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('steamInputAPI', {
|
||||||
|
start: () => ipcRenderer.invoke('steam-input-start'),
|
||||||
|
stop: () => ipcRenderer.send('steam-input-stop'),
|
||||||
|
getStatus: () => ipcRenderer.invoke('steam-input-status'),
|
||||||
|
onState: (handler) => {
|
||||||
|
if (typeof handler !== 'function') return () => {};
|
||||||
|
const wrapped = (_event, payload) => handler(payload);
|
||||||
|
ipcRenderer.on('steam-input-state', wrapped);
|
||||||
|
return () => ipcRenderer.removeListener('steam-input-state', wrapped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Relay context-menu commands from main to active renderer context (open new tabs etc.)
|
// Relay context-menu commands from main to active renderer context (open new tabs etc.)
|
||||||
ipcRenderer.on('context-menu-command', (event, payload) => {
|
ipcRenderer.on('context-menu-command', (event, payload) => {
|
||||||
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload }));
|
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload }));
|
||||||
|
|||||||
+199
-132
@@ -144,8 +144,13 @@ const state = {
|
|||||||
// Gamepad
|
// Gamepad
|
||||||
gamepadConnected: false,
|
gamepadConnected: false,
|
||||||
gamepadIndex: null,
|
gamepadIndex: null,
|
||||||
lastInput: { x: 0, y: 0 },
|
lastInput: {},
|
||||||
inputRepeatTimer: null,
|
inputRepeatTimer: null,
|
||||||
|
legacyGamepadEnabled: false,
|
||||||
|
useSteamInput: false,
|
||||||
|
steamInputStatus: null,
|
||||||
|
steamInputUnsubscribe: null,
|
||||||
|
steamInputCleanupBound: false,
|
||||||
|
|
||||||
// Virtual cursor for webview
|
// Virtual cursor for webview
|
||||||
cursorEnabled: false,
|
cursorEnabled: false,
|
||||||
@@ -648,12 +653,63 @@ function goForward() {
|
|||||||
// GAMEPAD SUPPORT
|
// 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) {
|
if (!navigator.getGamepads) {
|
||||||
console.warn('[BigPicture] Gamepad API not available in this environment');
|
console.warn('[BigPicture] Gamepad API not available in this environment');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.legacyGamepadEnabled = true;
|
||||||
|
|
||||||
// Note: On Linux (and some controllers like handheld integrated gamepads),
|
// Note: On Linux (and some controllers like handheld integrated gamepads),
|
||||||
// the `gamepadconnected` event may not fire until the first button press,
|
// the `gamepadconnected` event may not fire until the first button press,
|
||||||
// or at all. We rely on continuous polling for robustness.
|
// or at all. We rely on continuous polling for robustness.
|
||||||
@@ -731,6 +787,10 @@ function refreshActiveGamepad(isInitial = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pollGamepad() {
|
function pollGamepad() {
|
||||||
|
if (!state.legacyGamepadEnabled || state.useSteamInput) {
|
||||||
|
requestAnimationFrame(pollGamepad);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { active } = refreshActiveGamepad(false);
|
const { active } = refreshActiveGamepad(false);
|
||||||
if (active) {
|
if (active) {
|
||||||
handleGamepadInput(active);
|
handleGamepadInput(active);
|
||||||
@@ -740,9 +800,11 @@ function pollGamepad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleGamepadInput(gamepad) {
|
function handleGamepadInput(gamepad) {
|
||||||
|
if (state.useSteamInput) return;
|
||||||
|
|
||||||
// D-pad and left stick for navigation
|
// D-pad and left stick for navigation
|
||||||
const leftX = gamepad.axes[0];
|
const leftX = gamepad.axes[0] || 0;
|
||||||
const leftY = gamepad.axes[1];
|
const leftY = gamepad.axes[1] || 0;
|
||||||
|
|
||||||
// D-pad buttons (indices may vary by controller)
|
// D-pad buttons (indices may vary by controller)
|
||||||
const dpadUp = gamepad.buttons[12]?.pressed;
|
const dpadUp = gamepad.buttons[12]?.pressed;
|
||||||
@@ -756,122 +818,49 @@ function handleGamepadInput(gamepad) {
|
|||||||
const stickLeft = leftX < -CONFIG.STICK_DEADZONE;
|
const stickLeft = leftX < -CONFIG.STICK_DEADZONE;
|
||||||
const stickRight = 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;
|
const inWebviewMode = state.cursorEnabled && state.currentWebview;
|
||||||
|
|
||||||
// Combine inputs - but only use D-Pad when in webview mode
|
|
||||||
const up = inWebviewMode ? dpadUp : (dpadUp || stickUp);
|
const up = inWebviewMode ? dpadUp : (dpadUp || stickUp);
|
||||||
const down = inWebviewMode ? dpadDown : (dpadDown || stickDown);
|
const down = inWebviewMode ? dpadDown : (dpadDown || stickDown);
|
||||||
const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft);
|
const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft);
|
||||||
const right = inWebviewMode ? dpadRight : (dpadRight || stickRight);
|
const right = inWebviewMode ? dpadRight : (dpadRight || stickRight);
|
||||||
|
|
||||||
// Navigation with repeat prevention
|
processDirectionalInput('up', up);
|
||||||
const now = Date.now();
|
processDirectionalInput('down', down);
|
||||||
|
processDirectionalInput('left', left);
|
||||||
if (up && !state.lastInput.up) {
|
processDirectionalInput('right', right);
|
||||||
navigateFocus('up');
|
|
||||||
state.lastInput.up = now;
|
processButtonInput('a', gamepad.buttons[0]?.pressed, activateFocused);
|
||||||
} else if (!up) {
|
processButtonInput('b', gamepad.buttons[1]?.pressed, () => goBack());
|
||||||
state.lastInput.up = 0;
|
processButtonInput('x', gamepad.buttons[2]?.pressed, () => {
|
||||||
}
|
if (state.oskVisible) backspaceOSK();
|
||||||
|
});
|
||||||
if (down && !state.lastInput.down) {
|
processButtonInput('y', gamepad.buttons[3]?.pressed, () => {
|
||||||
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) {
|
|
||||||
if (state.oskVisible) {
|
if (state.oskVisible) {
|
||||||
appendToOSK(' ');
|
appendToOSK(' ');
|
||||||
} else {
|
} else {
|
||||||
openOSK('search');
|
openOSK('search');
|
||||||
}
|
}
|
||||||
state.lastInput.y = true;
|
});
|
||||||
} else if (!gamepad.buttons[3]?.pressed) {
|
processButtonInput('lb', gamepad.buttons[4]?.pressed, () => {
|
||||||
state.lastInput.y = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LB button (usually index 4) - Go back in webview / clear OSK
|
|
||||||
if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) {
|
|
||||||
if (state.oskVisible) {
|
if (state.oskVisible) {
|
||||||
clearOSK();
|
clearOSK();
|
||||||
} else if (state.currentSection === 'browse' && state.currentWebview) {
|
} else if (state.currentSection === 'browse' && state.currentWebview) {
|
||||||
goBack();
|
goBack();
|
||||||
}
|
}
|
||||||
state.lastInput.lb = true;
|
});
|
||||||
} else if (!gamepad.buttons[4]?.pressed) {
|
processButtonInput('rb', gamepad.buttons[5]?.pressed, () => {
|
||||||
state.lastInput.lb = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RB button (usually index 5) - Go forward in webview / submit OSK
|
|
||||||
if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) {
|
|
||||||
if (state.oskVisible) {
|
if (state.oskVisible) {
|
||||||
submitOSK();
|
submitOSK();
|
||||||
} else if (state.currentSection === 'browse' && state.currentWebview) {
|
} else if (state.currentSection === 'browse' && state.currentWebview) {
|
||||||
goForward();
|
goForward();
|
||||||
}
|
}
|
||||||
state.lastInput.rb = true;
|
});
|
||||||
} else if (!gamepad.buttons[5]?.pressed) {
|
processButtonInput('select', gamepad.buttons[8]?.pressed, () => {
|
||||||
state.lastInput.rb = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back/Select button (usually index 8) - Toggle sidebar when in webview
|
|
||||||
if (gamepad.buttons[8]?.pressed && !state.lastInput.select) {
|
|
||||||
if (state.currentSection === 'browse' && state.currentWebview) {
|
if (state.currentSection === 'browse' && state.currentWebview) {
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
}
|
}
|
||||||
state.lastInput.select = true;
|
});
|
||||||
} else if (!gamepad.buttons[8]?.pressed) {
|
processButtonInput('start', gamepad.buttons[9]?.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
|
|
||||||
if (state.currentSection === 'browse' && state.currentWebview) {
|
if (state.currentSection === 'browse' && state.currentWebview) {
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
} else if (state.currentSection !== 'settings') {
|
} else if (state.currentSection !== 'settings') {
|
||||||
@@ -879,60 +868,138 @@ function handleGamepadInput(gamepad) {
|
|||||||
} else {
|
} else {
|
||||||
switchSection('home');
|
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) {
|
if (state.cursorEnabled && state.currentWebview) {
|
||||||
// Right stick for cursor movement
|
|
||||||
const rightX = gamepad.axes[2] || 0;
|
const rightX = gamepad.axes[2] || 0;
|
||||||
const rightY = gamepad.axes[3] || 0;
|
const rightY = gamepad.axes[3] || 0;
|
||||||
|
|
||||||
// Apply deadzone
|
|
||||||
const deadzone = 0.15;
|
const deadzone = 0.15;
|
||||||
const moveX = Math.abs(rightX) > deadzone ? rightX : 0;
|
const moveX = Math.abs(rightX) > deadzone ? rightX : 0;
|
||||||
const moveY = Math.abs(rightY) > deadzone ? rightY : 0;
|
const moveY = Math.abs(rightY) > deadzone ? rightY : 0;
|
||||||
|
|
||||||
if (moveX !== 0 || moveY !== 0) {
|
if (moveX !== 0 || moveY !== 0) {
|
||||||
moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed);
|
moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left stick for scrolling in webview mode
|
|
||||||
const scrollDeadzone = 0.25;
|
const scrollDeadzone = 0.25;
|
||||||
const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0;
|
const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0;
|
||||||
const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0;
|
const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0;
|
||||||
|
|
||||||
if (scrollX !== 0 || scrollY !== 0) {
|
if (scrollX !== 0 || scrollY !== 0) {
|
||||||
scrollWebview(scrollY * 20, scrollX * 20);
|
scrollWebview(scrollY * 20, scrollX * 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right trigger (index 7) - Left click
|
processButtonInput('rt', gamepad.buttons[7]?.pressed, () => virtualClick());
|
||||||
if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) {
|
processButtonInput('lt', gamepad.buttons[6]?.pressed, () => virtualClick(true));
|
||||||
virtualClick();
|
processButtonInput('rs', gamepad.buttons[11]?.pressed, handleCursorSpeedToggle);
|
||||||
state.lastInput.rt = true;
|
}
|
||||||
} else if (!gamepad.buttons[7]?.pressed) {
|
}
|
||||||
state.lastInput.rt = false;
|
|
||||||
|
function handleSteamInputState(payload) {
|
||||||
|
if (!payload || !payload.connected || !payload.controller) {
|
||||||
|
state.useSteamInput = false;
|
||||||
|
if (!state.legacyGamepadEnabled) {
|
||||||
|
initLegacyGamepadSupport();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
// Left trigger (index 6) - Right click
|
}
|
||||||
if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) {
|
|
||||||
virtualClick(true);
|
state.gamepadConnected = true;
|
||||||
state.lastInput.lt = true;
|
state.useSteamInput = true;
|
||||||
} else if (!gamepad.buttons[6]?.pressed) {
|
const controller = payload.controller;
|
||||||
state.lastInput.lt = false;
|
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) {
|
const shoulderLeftPressed = !!buttons.shoulderLeft || triggers.left > 0.6;
|
||||||
state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15);
|
const shoulderRightPressed = !!buttons.shoulderRight || triggers.right > 0.6;
|
||||||
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
|
|
||||||
state.lastInput.rs = true;
|
processButtonInput('lb', shoulderLeftPressed, () => {
|
||||||
} else if (!gamepad.buttons[11]?.pressed) {
|
if (state.oskVisible) {
|
||||||
state.lastInput.rs = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
const ACTION_SET_NAME = 'nebula_bigpicture';
|
||||||
|
const DIGITAL_ACTIONS = {
|
||||||
|
up: 'bp_nav_up',
|
||||||
|
down: 'bp_nav_down',
|
||||||
|
left: 'bp_nav_left',
|
||||||
|
right: 'bp_nav_right',
|
||||||
|
confirm: 'bp_confirm',
|
||||||
|
back: 'bp_back',
|
||||||
|
oskBackspace: 'bp_osk_backspace',
|
||||||
|
oskSpace: 'bp_open_search',
|
||||||
|
shoulderLeft: 'bp_shoulder_left',
|
||||||
|
shoulderRight: 'bp_shoulder_right',
|
||||||
|
toggleSidebar: 'bp_toggle_sidebar',
|
||||||
|
menu: 'bp_menu',
|
||||||
|
select: 'bp_select',
|
||||||
|
cursorPrimary: 'bp_cursor_primary',
|
||||||
|
cursorSecondary: 'bp_cursor_secondary',
|
||||||
|
cursorSpeed: 'bp_cursor_speed',
|
||||||
|
showOsk: 'bp_show_osk'
|
||||||
|
};
|
||||||
|
const ANALOG_ACTIONS = {
|
||||||
|
nav: 'bp_nav',
|
||||||
|
cursor: 'bp_cursor_vector',
|
||||||
|
scroll: 'bp_scroll_vector',
|
||||||
|
triggerLeft: 'bp_trigger_left',
|
||||||
|
triggerRight: 'bp_trigger_right'
|
||||||
|
};
|
||||||
|
|
||||||
|
class SteamInputManager {
|
||||||
|
constructor() {
|
||||||
|
this.sdk = null;
|
||||||
|
this.client = null;
|
||||||
|
this.input = null;
|
||||||
|
this.available = false;
|
||||||
|
this.handles = {
|
||||||
|
actionSet: 0n,
|
||||||
|
digital: {},
|
||||||
|
analog: {}
|
||||||
|
};
|
||||||
|
this.handlesReady = false;
|
||||||
|
this.handlesReason = 'uninitialized';
|
||||||
|
this.subscribers = new Map();
|
||||||
|
this.cachedState = { connected: false, timestamp: Date.now() };
|
||||||
|
this.pollInterval = null;
|
||||||
|
this.lastCursorSpeedToggle = 0;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (process.env.NEBULA_DISABLE_STEAMWORKS) {
|
||||||
|
this.handlesReason = 'disabled-via-env';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let steamworks;
|
||||||
|
try {
|
||||||
|
// Lazy require so environments without the redistributable don't crash startup.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
steamworks = require('steamworks.js');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] steamworks.js unavailable:', err.message);
|
||||||
|
this.handlesReason = 'module-missing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sdk = steamworks;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appId = this.resolveAppId();
|
||||||
|
this.client = steamworks.init(appId);
|
||||||
|
this.input = this.client?.input;
|
||||||
|
if (!this.input) throw new Error('Steam Input interface missing');
|
||||||
|
this.input.init();
|
||||||
|
if (typeof steamworks.electronEnableSteamOverlay === 'function') {
|
||||||
|
steamworks.electronEnableSteamOverlay();
|
||||||
|
}
|
||||||
|
this.available = true;
|
||||||
|
this.bootstrapHandles();
|
||||||
|
this.startPolling();
|
||||||
|
console.log('[SteamInput] Steamworks initialized', {
|
||||||
|
appId: appId || 'steam_appid.txt',
|
||||||
|
handlesReady: this.handlesReady
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to initialize Steamworks:', err.message);
|
||||||
|
this.client = null;
|
||||||
|
this.input = null;
|
||||||
|
this.available = false;
|
||||||
|
this.handlesReason = 'init-failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAppId() {
|
||||||
|
const envId = Number(process.env.STEAM_APP_ID || process.env.STEAMWORKS_APPID || process.env.STEAM_APPID);
|
||||||
|
if (Number.isFinite(envId) && envId > 0) {
|
||||||
|
return envId;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapHandles() {
|
||||||
|
if (!this.input) return;
|
||||||
|
try {
|
||||||
|
const actionSet = this.input.getActionSet?.(ACTION_SET_NAME) || 0n;
|
||||||
|
const digital = {};
|
||||||
|
for (const [key, actionName] of Object.entries(DIGITAL_ACTIONS)) {
|
||||||
|
digital[key] = this.input.getDigitalAction?.(actionName) || 0n;
|
||||||
|
}
|
||||||
|
const analog = {};
|
||||||
|
for (const [key, actionName] of Object.entries(ANALOG_ACTIONS)) {
|
||||||
|
analog[key] = this.input.getAnalogAction?.(actionName) || 0n;
|
||||||
|
}
|
||||||
|
this.handles = { actionSet, digital, analog };
|
||||||
|
|
||||||
|
const directionalDigitalReady = Boolean(digital.up && digital.down && digital.left && digital.right);
|
||||||
|
const confirmReady = Boolean(digital.confirm);
|
||||||
|
const backReady = Boolean(digital.back);
|
||||||
|
const analogNavReady = Boolean(analog.nav);
|
||||||
|
this.handlesReady = Boolean(actionSet && (directionalDigitalReady || analogNavReady) && confirmReady && backReady);
|
||||||
|
this.handlesReason = this.handlesReady ? 'ok' : 'handles-missing';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to read action handles:', err.message);
|
||||||
|
this.handlesReady = false;
|
||||||
|
this.handlesReason = 'handles-error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling() {
|
||||||
|
if (!this.available || !this.input || this.pollInterval) return;
|
||||||
|
this.pollInterval = setInterval(() => this.tick(), 16);
|
||||||
|
if (this.pollInterval && typeof this.pollInterval.unref === 'function') {
|
||||||
|
this.pollInterval.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if (!this.available || !this.input) return;
|
||||||
|
try {
|
||||||
|
this.sdk?.runCallbacks?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] runCallbacks failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = { connected: false, reason: this.handlesReason, timestamp: Date.now() };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controllers = this.input.getControllers?.() || [];
|
||||||
|
const controller = controllers.find(Boolean);
|
||||||
|
if (controller && this.handlesReady) {
|
||||||
|
this.activateActionSet(controller);
|
||||||
|
payload = {
|
||||||
|
connected: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
controller: this.buildControllerState(controller),
|
||||||
|
reason: 'ok'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to query controller state:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedState = payload;
|
||||||
|
if (!this.subscribers.size) return;
|
||||||
|
|
||||||
|
for (const [id, wc] of this.subscribers.entries()) {
|
||||||
|
if (wc.isDestroyed?.()) {
|
||||||
|
this.subscribers.delete(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
wc.send('steam-input-state', payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to send state to renderer:', err.message);
|
||||||
|
this.subscribers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activateActionSet(controller) {
|
||||||
|
if (!this.handles.actionSet) return;
|
||||||
|
try {
|
||||||
|
controller.activateActionSet?.(this.handles.actionSet);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to activate action set:', err.message);
|
||||||
|
this.handlesReady = false;
|
||||||
|
this.handlesReason = 'activate-failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildControllerState(controller) {
|
||||||
|
const navVector = this.readAnalog(controller, this.handles.analog.nav);
|
||||||
|
const cursorVector = this.readAnalog(controller, this.handles.analog.cursor);
|
||||||
|
const scrollVector = this.readAnalog(controller, this.handles.analog.scroll);
|
||||||
|
const triggerLeft = this.readAnalog(controller, this.handles.analog.triggerLeft);
|
||||||
|
const triggerRight = this.readAnalog(controller, this.handles.analog.triggerRight);
|
||||||
|
|
||||||
|
const nav = {
|
||||||
|
up: this.readDigital(controller, this.handles.digital.up) || navVector.y < -0.5,
|
||||||
|
down: this.readDigital(controller, this.handles.digital.down) || navVector.y > 0.5,
|
||||||
|
left: this.readDigital(controller, this.handles.digital.left) || navVector.x < -0.5,
|
||||||
|
right: this.readDigital(controller, this.handles.digital.right) || navVector.x > 0.5
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = {
|
||||||
|
confirm: this.readDigital(controller, this.handles.digital.confirm),
|
||||||
|
back: this.readDigital(controller, this.handles.digital.back),
|
||||||
|
oskBackspace: this.readDigital(controller, this.handles.digital.oskBackspace),
|
||||||
|
oskSpace: this.readDigital(controller, this.handles.digital.oskSpace),
|
||||||
|
shoulderLeft: this.readDigital(controller, this.handles.digital.shoulderLeft),
|
||||||
|
shoulderRight: this.readDigital(controller, this.handles.digital.shoulderRight),
|
||||||
|
toggleSidebar: this.readDigital(controller, this.handles.digital.toggleSidebar) || this.readDigital(controller, this.handles.digital.select),
|
||||||
|
menu: this.readDigital(controller, this.handles.digital.menu),
|
||||||
|
cursorPrimary: this.readDigital(controller, this.handles.digital.cursorPrimary),
|
||||||
|
cursorSecondary: this.readDigital(controller, this.handles.digital.cursorSecondary),
|
||||||
|
cursorSpeed: this.readDigital(controller, this.handles.digital.cursorSpeed),
|
||||||
|
showOsk: this.readDigital(controller, this.handles.digital.showOsk)
|
||||||
|
};
|
||||||
|
|
||||||
|
const analog = {
|
||||||
|
nav: navVector,
|
||||||
|
cursor: cursorVector,
|
||||||
|
scroll: scrollVector,
|
||||||
|
triggers: {
|
||||||
|
left: Math.max(Math.abs(triggerLeft.x), Math.abs(triggerLeft.y)),
|
||||||
|
right: Math.max(Math.abs(triggerRight.x), Math.abs(triggerRight.y))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handle: controller.getHandle?.() || 0n,
|
||||||
|
type: controller.getType?.() || 'Unknown',
|
||||||
|
nav,
|
||||||
|
buttons,
|
||||||
|
analog
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
readDigital(controller, handle) {
|
||||||
|
if (!handle || typeof controller.isDigitalActionPressed !== 'function') return false;
|
||||||
|
try {
|
||||||
|
return controller.isDigitalActionPressed(handle);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to read digital action:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readAnalog(controller, handle) {
|
||||||
|
if (!handle || typeof controller.getAnalogActionVector !== 'function') {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const vec = controller.getAnalogActionVector(handle) || { x: 0, y: 0 };
|
||||||
|
return {
|
||||||
|
x: Number.isFinite(vec.x) ? vec.x : 0,
|
||||||
|
y: Number.isFinite(vec.y) ? vec.y : 0
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to read analog action:', err.message);
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(webContents) {
|
||||||
|
if (!webContents) return this.getStatus();
|
||||||
|
const id = webContents.id;
|
||||||
|
this.subscribers.set(id, webContents);
|
||||||
|
webContents.once('destroyed', () => {
|
||||||
|
this.subscribers.delete(id);
|
||||||
|
});
|
||||||
|
if (this.cachedState) {
|
||||||
|
try {
|
||||||
|
webContents.send('steam-input-state', this.cachedState);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Failed to push cached state:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(webContents) {
|
||||||
|
if (!webContents) return;
|
||||||
|
this.subscribers.delete(webContents.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
const steamDeck = Boolean(this.client?.utils?.isSteamRunningOnSteamDeck?.());
|
||||||
|
return {
|
||||||
|
enabled: this.available && this.handlesReady,
|
||||||
|
available: this.available,
|
||||||
|
handlesReady: this.handlesReady,
|
||||||
|
reason: this.handlesReason,
|
||||||
|
steamDeck
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.input?.shutdown?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SteamInput] Shutdown failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SteamInputManager;
|
||||||
Reference in New Issue
Block a user