diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index cbfa9d8..a3a1ab0 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -361,6 +361,7 @@ app.whenReady().then(() => { axis: { file: 'axis-window.html', width: 280, height: 90 }, roll: { file: 'roll-window.html', width: 240, height: 170 }, controllers: { file: 'controllers-window.html', width: 320, height: 460 }, + 'multi-controllers': { file: 'multi-controllers-window.html', width: 340, height: 500 }, }; ipcMain.handle('open-hud-window', async (_event, { kind, profile, greenScreen }) => { diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 24fd229..a655358 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -12,7 +12,7 @@ import * as THREE from 'three'; import { ControllerOverlay, detectControllerType, PROFILES, GyroGimbal } from '@usersfirst/controller-visualizer'; -import { ControllerRegistry, SensorFusion, analyzeImuStep, SteamControllerDriver, ControllerManager } from '@usersfirst/controller-core'; +import { ControllerRegistry, SensorFusion, analyzeImuStep, SteamControllerDriver, ControllerManager, isPresentableEntry } from '@usersfirst/controller-core'; import { recordStep, buildReport, exportReport, stepsForEntry, parseImuSamples, areasForSteps, filterStepsByAreas, AREA_LABELS, STEP_AREAS } from './test-report.js'; @@ -169,10 +169,11 @@ const listManager = new ControllerManager({ slotIds: ['_ovl'] }); async function initControllerList() { if (!navigator.hid) return; try { - const approved = await navigator.hid.getDevices(); - for (const d of approved) { - if (ControllerRegistry.isKnownDevice(d)) await listManager.poolDevice(d); - } + // WebHID-first: pool every approved, known HID device (no Gamepad-API gate). + // Shared with the multi/lobby apps so all three boot HID the same way — this + // used to be an inline loop here that drifted from the gated version those + // apps called. See ControllerManager.autoPoolApprovedHid. + await listManager.autoPoolApprovedHid(); listManager.wireHidHotplug(); } catch (e) { console.warn('[overlay] controller-pool init failed', e); } } @@ -1096,7 +1097,7 @@ function scheduleGyroConnect() { if (gyroActive) return; console.log('Auto-connecting gyro for', currentControllerType, '...'); try { - await connectControllerGyro(); + await connectControllerGyro(false); // boot auto-connect: no gesture → getDevices only, never the blocking scan if (gyroActive) { console.log('Gyro auto-connected successfully'); } else { @@ -1123,7 +1124,7 @@ function cancelGyroConnect() { * Step 2: requestDevice() — triggers Electron's select-hid-device handler * which auto-approves. Also works in browsers with user gesture. */ -async function connectControllerGyro() { +async function connectControllerGyro(allowRequest = true) { if (hidDevice && gyroActive) return; if (!navigator.hid) return; @@ -1159,8 +1160,12 @@ async function connectControllerGyro() { console.log('connectControllerGyro: getDevices failed:', err.message); } - // Step 2: requestDevice() if no granted device - if (!device) { + // Step 2: requestDevice() if no granted device — only when the caller allows + // it (a real user gesture). The boot auto-connect timer has no gesture, so a + // requestDevice there just throws "Must be handling a user gesture" (and in + // Electron would run the blocking system HID scan); skip it and let the user's + // explicit Connect click do the granting. + if (!device && allowRequest) { console.log('connectControllerGyro: trying requestDevice()...'); try { const devices = await navigator.hid.requestDevice({ filters }); @@ -1208,7 +1213,12 @@ async function finishGyroConnect(device) { // Steam Controller Puck: the picked handle may be a sibling that never emits // STATE reports — designate the same-vid:pid interface that IS receiving them. - if (entry.driver?.constructor?.needsSiblingFanout) { + // Only reroute a SILENT handle, though: the 2026 Puck is a multi-receiver and + // each paired body streams on its own interface, so a streaming handle is + // already its own unit. Rerouting a live handle to rx[0] would make selecting + // the second controller silently snap back to the first (see the multi-Steam + // per-unit rollup below). + if (entry.driver?.constructor?.needsSiblingFanout && !(entry.hidActiveSince > 0)) { const rx = pool().filter((e) => forVp(e) && e.hidActiveSince > 0); if (rx.length) entry = rx[0]; } @@ -1372,17 +1382,21 @@ function overlayControllerRows() { rows.push({ key: 'active', sortId: deviceIdFor(hidDevice), name: _ctrlName(vp, hidDevice.productName), state: 'SELECTED', conn, vp, serial: serialForDevice(vp, conn), active: _padActive(readGamepad()), selected: true }); } - // Pool entries → rows. A fan-out device (Steam Controller Puck: 5 same-vid:pid - // HID interfaces on ONE physical unit) rolls up to a SINGLE row; other handles - // are one row each. Siblings of the SELECTED controller are hidden. - // TODO: multiple Steam Controllers on one Puck will need per-unit rollup (issue). + // Pool entries → rows. A fan-out device (Steam Controller Puck) exposes several + // same-vid:pid HID interfaces, but — crucially — the 2026 Puck is a MULTI- + // receiver: each paired controller body streams STATE on its OWN vendor + // interface (verified: body#1→MI_02, body#2→MI_03). So a fan-out interface is + // its own logical controller *iff it is actually streaming* (hidActiveSince>0); + // the Puck's silent siblings (keyboard/mouse collections, unpaired receiver + // slots) are NOT controllers and must not appear as phantom rows. Non-fan-out + // handles are one row each as before. (This replaces the old fan:vid:pid rollup + // that collapsed every Puck interface — and thus BOTH bodies — into one row.) const groups = new Map(); for (const entry of listManager._hidPool.values()) { const d = entry.device; if (d === hidDevice) continue; // the SELECTED device is now pooled too — shown as its own row above - const isFanout = !!(entry.driver && entry.driver.constructor && entry.driver.constructor.needsSiblingFanout); - if (isFanout && hidDevice && d.vendorId === hidDevice.vendorId && d.productId === hidDevice.productId) continue; - const gk = isFanout ? ('fan:' + d.vendorId + ':' + d.productId) : ('dev:' + deviceIdFor(d)); + if (!isPresentableEntry(entry)) continue; // hide idle Puck siblings (shared core filter) + const gk = 'dev:' + deviceIdFor(d); // per-interface row (per-unit for the multi-receiver Puck) const a = _padActive(entry.synthetic); const g = groups.get(gk); if (g) { g.active = g.active || a; } else groups.set(gk, { device: d, entry, active: a }); @@ -1405,6 +1419,22 @@ function overlayControllerRows() { // Stable order by first-sighting (deviceId) so SELECTING a controller does NOT // reorder the list — the row you clicked stays put instead of jumping to top. rows.sort((x, y) => x.sortId - y.sortId); + // Disambiguate identical display names: two Steam Controller bodies on one + // multi-receiver Puck share name + vid:pid + serial (the serial is the PUCK's, + // not the body's — see [[multi-steam-controller]]), so the rows are otherwise + // indistinguishable. Append a stable ' #n' by first-sighting order (sortId, + // already applied) so the user can tell them apart. Also covers any two + // truly-identical pads (e.g. two DS4s). Single-of-a-kind names are untouched. + const nameCounts = new Map(); + for (const r of rows) nameCounts.set(r.name, (nameCounts.get(r.name) || 0) + 1); + const nameSeen = new Map(); + for (const r of rows) { + if (nameCounts.get(r.name) > 1) { + const n = (nameSeen.get(r.name) || 0) + 1; + nameSeen.set(r.name, n); + r.name = `${r.name} #${n}`; + } + } return rows; } @@ -1456,6 +1486,10 @@ function forwardControllerList() { const _PHANTOM_MS = 3000; function evictPhantoms(now) { for (const entry of [...listManager._hidPool.values()]) { + // Never evict a fan-out (Steam Puck) interface for silence: it's present as + // long as the dongle is plugged, just idle until a body powers on. Evicting + // it would drop a receiver slot that a controller is about to stream into. + if (entry.driver?.constructor?.needsSiblingFanout) continue; if (entry.lastRawReportAt === 0 && typeof entry.pooledAt === 'number' && (now - entry.pooledAt) > _PHANTOM_MS) { const dev = entry.device; console.log('[overlay] evicting phantom (no reports in ' + Math.round((now - entry.pooledAt)) + 'ms):', diff --git a/apps/overlay/src/js/lobby-app.js b/apps/overlay/src/js/lobby-app.js index a5bdb7f..1995c29 100644 --- a/apps/overlay/src/js/lobby-app.js +++ b/apps/overlay/src/js/lobby-app.js @@ -15,9 +15,14 @@ // into the game (sync-controller-core). // ============================================================ -import { ControllerManager, ControllerRegistry } from '@usersfirst/controller-core'; +import { ControllerManager, ControllerRegistry, MAX_CONTROLLERS, playerSlotIds } from '@usersfirst/controller-core'; -const SLOT_IDS = ['P1', 'P2', 'P3', 'P4']; +// Pre-allocate a generous slot pool (shared core constant) so MORE controllers +// than there are seats can be RECOGNIZED (ACTIVE) and wait in the lounge for a +// seat to open — the modes cap seats at 4 (versus 2v2), but a 5th+ controller +// should still be seen and queue, not silently do nothing (it had nowhere to go +// with only 4 slots). Only claimed slots cost anything. +const SLOT_IDS = playerSlotIds(); const manager = new ControllerManager({ slotIds: SLOT_IDS }); window.__manager = manager; @@ -281,22 +286,52 @@ function updateControllerCount() { const el = $('ctrl-count'); if (!el) return; if (pairMsg && performance.now() < pairMsgUntil) { el.textContent = pairMsg; return; } const claimed = manager.slots.filter((s) => s.state === 'claimed').length; - const pooled = manager._hidPool.size; + // Count only usable pooled controllers — excludes idle Steam Puck receiver + // interfaces (shared core filter), same as the list. + const pooled = manager.presentablePoolEntries().length; el.textContent = (claimed === 0 && pooled === 0) ? 'no controllers — press a button or Pair →' : `${claimed} in use${pooled ? ` · ${pooled} available` : ''}`; } -// Approve/pool the next controller (main.js auto-selects a not-yet-picked one). -// requestDevice needs the click as its user gesture; the Steam Controller is -// HID-only, so it can only be recognized after this. Gives visible feedback: -// after boot auto-pools everything, there's often nothing new to pair, and the -// old silent no-op read as "the button is broken". +// Approve/pool the next controller. requestDevice needs the click as its user +// gesture; the Steam Controller is HID-only, so it can only be recognized after +// this. Gives visible feedback: after boot auto-pools everything, there's often +// nothing new to pair. +// +// Environment-aware, so a redundant click doesn't stall (issue seen in Electron): +// • Browser — the requestDevice picker IS the only way to grant access, so +// always allow it (prompt: true). +// • Electron — the app auto-grants + auto-pools, so requestDevice is only +// needed for the FIRST grant on a fresh profile. It also enumerates every +// system HID device and briefly blocks. So: pool an already-approved device +// cheaply; only run the scan when nothing is paired yet (first-time setup) +// or the user explicitly asks for it by clicking again ("force scan"). +const IS_ELECTRON = navigator.userAgent.includes('Electron'); +let _forceScanUntil = 0; async function pairController() { const free = SLOT_IDS.find((id) => manager.getSlot(id).state !== 'claimed') || SLOT_IDS[0]; const before = manager._hidPool.size; flashPairMsg('pairing…'); openCtrlPanel(true); try { - const dev = await manager.connectHidForSlot(free); + let dev; + if (!IS_ELECTRON) { + dev = await manager.connectHidForSlot(free, { prompt: true }); + } else { + dev = await manager.connectHidForSlot(free, { prompt: false }); // cheap: pool an already-approved device + if (!dev) { + const nothingPairedYet = manager._hidPool.size === 0; // fresh profile — scan is justified + const armed = performance.now() < _forceScanUntil; // user clicked again to force it + if (nothingPairedYet || armed) { + _forceScanUntil = 0; + dev = await manager.connectHidForSlot(free, { prompt: true }); // the (blocking) system HID scan + } else { + const n = manager.presentablePoolEntries().length; + _forceScanUntil = performance.now() + 5000; + flashPairMsg(n ? `✓ ${n} connected — click again to scan for a new one` : 'no controller found — click again to scan'); + return; + } + } + } const after = manager._hidPool.size; if (dev) flashPairMsg('paired ✓ ' + (dev.productName || 'controller')); else if (after > before) flashPairMsg('paired ✓'); @@ -374,7 +409,9 @@ function ctrlEntries(pads) { }); } let hi = 0; - for (const entry of manager._hidPool.values()) { + // presentablePoolEntries hides idle Steam Puck receiver interfaces (kept pooled + // for power-on, but not usable controllers) — shared core filter. + for (const entry of manager.presentablePoolEntries()) { const nm = (entry.driver && entry.driver.entry && entry.driver.entry.name) || (entry.device && entry.device.productName) || 'Controller'; items.push({ // index in the key: two identical-vid:pid devices (real DS4 + GameSir spoof) @@ -529,6 +566,10 @@ async function boot() { $('btn-pair').addEventListener('click', pairController); $('btn-pair-global').addEventListener('click', pairController); $('btn-ctrl-list').addEventListener('click', toggleCtrlPanel); + $('btn-close-app').addEventListener('click', () => { + if (window.electronAPI && window.electronAPI.quit) window.electronAPI.quit(); + else if (window.close) window.close(); + }); // Per-unit serial/MAC inventory from the Electron main process (no-op on web). if (window.electronAPI) { diff --git a/apps/overlay/src/js/multi-app.js b/apps/overlay/src/js/multi-app.js index 2901cec..34ef3cb 100644 --- a/apps/overlay/src/js/multi-app.js +++ b/apps/overlay/src/js/multi-app.js @@ -1,34 +1,60 @@ // ============================================================ -// MULTI-APP.JS — Multi-controller overlay view layer +// MULTI-APP.JS — Dynamic multi-controller overlay view layer // ============================================================ // -// Thin view on top of shared/controller-manager.js. The manager owns -// all slot state, claim/release lifecycle, HID pairing, and sensor -// fusion. This file only: -// - instantiates 3D ControllerOverlay canvases -// - wires slot state changes to DOM text/classes -// - runs the raf loop and forwards pads + gyro to overlays -// - renders diagnostics panels + debug strip +// Thin view on top of controller-core's ControllerManager. The manager owns all +// slot state, claim/release lifecycle, HID pairing, and sensor fusion. This file +// only renders it. // -// See shared/controller-manager.js for the state machine, #222 for -// the design doc, #224 for the consolidation plan. +// DESIGN (dynamic roster): there are no fixed "Press to join" panels. The +// manager holds a generous pool of empty slots (MAX_PLAYERS); a PLAYER n panel +// is created the moment a controller CLAIMS a slot (goes ACTIVE) and disposed +// when it releases — so the grid grows/shrinks with the number of active +// players. Every connected-but-idle controller shows in the AVAILABLE list +// (toggled by the List button); pressing a button on one promotes it to its own +// PLAYER panel. The slot ordinal is the player number, so numbers are sticky +// across a brief drop (the manager's orphan→reconnect keeps the seat). +// +// WebHID-first: controllers are pooled over WebHID (autoPoolApprovedHid, gate- +// free) and the manager's ingestFrame claims them; the Gamepad API / XInput is +// the fallback for pads without a WebHID driver (Xbox). A periodic re-pool picks +// up a controller powered on AFTER launch. See [[multi-steam-controller]]. // ============================================================ import { ControllerOverlay, detectControllerType } from '@usersfirst/controller-visualizer'; -import { ControllerRegistry, ControllerManager, gamepadHasActivity } from '@usersfirst/controller-core'; +import { ControllerRegistry, ControllerManager, MAX_CONTROLLERS, playerSlotIds } from '@usersfirst/controller-core'; + +// Pre-allocated slot cap (shared core constant). Panels/canvases are only created +// for CLAIMED slots, so the real cost scales with active players, not this number. +const MAX_PLAYERS = MAX_CONTROLLERS; +const SLOT_IDS = playerSlotIds(); -const SLOT_IDS = ['P1', 'P2', 'P3', 'P4']; +// Re-pool interval: a controller powered on after launch streams into an +// already-enumerated interface (no WebHID 'connect' event), so we periodically +// re-run autoPoolApprovedHid to pick it up. Cheap (getDevices + a set check). +const RESCAN_MS = 3000; const manager = new ControllerManager({ slotIds: SLOT_IDS }); -// Expose for DevTools debugging. -window.__manager = manager; +window.__manager = manager; // DevTools + +// ── Per-player accent color (generated for all MAX_PLAYERS) ── +// Golden-angle hue spacing keeps 16 players visually distinct. Applied inline so +// we don't need a CSS class per player. +function playerHue(playerNum) { return Math.round(((playerNum - 1) * 137.508) % 360); } +function applyPlayerColor(root, titleEl, playerNum) { + const h = playerHue(playerNum); + root.style.borderColor = `hsla(${h}, 72%, 62%, 0.45)`; + root.style.boxShadow = `0 0 30px hsla(${h}, 72%, 52%, 0.18) inset`; + if (titleEl) titleEl.style.color = `hsl(${h}, 72%, 72%)`; +} -// ── View: per-slot DOM wiring ── +// ── View: per-slot DOM wiring (created on claim, disposed on release) ── class SlotView { - constructor(slot, root) { + constructor(slot, root, playerNum) { this.slot = slot; this.root = root; + this.playerNum = playerNum; this.canvas = root.querySelector('[data-role="canvas"]'); this.promptEl = root.querySelector('[data-role="prompt"]'); this.hintEl = root.querySelector('[data-role="hint"]'); @@ -42,14 +68,13 @@ class SlotView { this.overlay = null; this.controllerType = 'dualsense'; this._diagLastUpdate = 0; + this._unsub = null; - root.classList.toggle('p1', slot.id === 'P1'); - root.classList.toggle('p2', slot.id === 'P2'); - root.classList.toggle('p3', slot.id === 'P3'); - root.classList.toggle('p4', slot.id === 'P4'); root.setAttribute('data-slot', slot.id); const titleEl = root.querySelector('.slot-title'); - if (titleEl) titleEl.textContent = `Player ${slot.id.slice(1)}`; + this.titleEl = titleEl; + if (titleEl) titleEl.textContent = `Player ${playerNum}`; + applyPlayerColor(root, titleEl, playerNum); if (this.connectBtn) { this.connectBtn.addEventListener('click', () => { @@ -74,8 +99,24 @@ class SlotView { }); } - slot.on((s, reason, data) => this._onSlotChange(reason, data)); - this._renderEmpty(); + // Resize the WebGL renderer whenever THIS panel's canvas changes size. The + // grid reflows when a player joins/leaves, which resizes every other panel's + // canvas (CSS) but not its drawing buffer — leaving the 3D model stretched + // until a manual window nudge. Observing the canvas fixes each panel the + // instant it reflows. (renderer.setSize(w,h,false) doesn't touch CSS size, + // so this can't feed back into a ResizeObserver loop.) + this._resizeObserver = null; + if (typeof ResizeObserver !== 'undefined' && this.canvas) { + this._resizeObserver = new ResizeObserver(() => this._resize()); + this._resizeObserver.observe(this.canvas); + } + + this._unsub = slot.on((s, reason, data) => this._onSlotChange(reason, data)); + // The view is created the frame AFTER the claim, so we missed the initial + // 'claimed'/'hid-bound' events — render current state directly instead. + this._renderClaimed(); + this._renderHidBadge(!!slot._hidEntry); + if (slot.state === 'orphan') { this.root.classList.add('orphan'); this.hintEl.textContent = 'Reconnecting…'; } } async initOverlay(controllerType) { @@ -89,6 +130,14 @@ class SlotView { this._resize(); } + dispose() { + if (this._resizeObserver) { try { this._resizeObserver.disconnect(); } catch {} this._resizeObserver = null; } + if (this._unsub) { try { this._unsub(); } catch {} this._unsub = null; } + try { this.overlay?.dispose?.(); } catch {} + this.overlay = null; + this.root.remove(); + } + _resize() { if (!this.overlay || !this.canvas) return; const w = this.canvas.clientWidth; @@ -100,11 +149,9 @@ class SlotView { const s = this.slot; switch (reason) { case 'claimed': + this.root.classList.remove('orphan'); this._renderClaimed(); break; - case 'released': - this._renderEmpty(); - break; case 'orphaned': this.hintEl.textContent = 'Reconnecting…'; this.root.classList.add('orphan'); @@ -119,23 +166,14 @@ class SlotView { this._renderRing(s.ringPct); break; case 'hid-report': - // Forward touchpad data to the 3D overlay. if (data?.touchpad && this.overlay?.updateTouchpad) { this.overlay.updateTouchpad(data.touchpad, data.touchpadButton); } break; + // 'released' is handled by the loop's view reconciliation (disposes us). } } - _renderEmpty() { - this.root.classList.remove('claimed', 'orphan'); - this.root.classList.add('empty'); - this.promptEl.innerHTML = 'Press any button to join'; - this.hintEl.textContent = ''; - this.subEl.textContent = ''; - this._renderRing(0); - } - _renderClaimed() { const s = this.slot; this.root.classList.remove('empty', 'orphan'); @@ -144,9 +182,6 @@ class SlotView { this.hintEl.textContent = 'Hold PS/Home 2s to release'; this.subEl.textContent = (s.controllerId || '').slice(0, 28); - // Swap 3D model to match the claimed controller. Prefer the dictionary - // entry's controllerProfile (so a registered clone with its own GLB - // wins) and fall back to the visualizer's id-pattern sniff. const idInfo = ControllerRegistry.identifyFromGamepadId(s.controllerLabel || ''); const desired = idInfo?.controllerProfile || detectControllerType(s.controllerLabel || '') || 'dualsense'; if (desired !== this.controllerType && this.overlay) { @@ -173,54 +208,166 @@ class SlotView { if (this.calibBtn) this.calibBtn.style.display = on ? '' : 'none'; } - /** - * Called each frame. Pushes the effective gamepad + gyro quaternion into - * the 3D overlay, and updates the calibration-finished hint text. - */ tick(pads) { const s = this.slot; - // Calibration-complete transition const isCalibrating = !!(s.fusion && s.fusion.calibrating); if (s._wasCalibrating && !isCalibrating) { s.fusion?.reset(); - this.hintEl.textContent = s.state === 'claimed' - ? 'Hold PS/Home 2s to release' - : ''; + this.hintEl.textContent = s.state === 'claimed' ? 'Hold PS/Home 2s to release' : ''; } s._wasCalibrating = isCalibrating; const gp = s.effectiveGamepad(pads); - // Gyro only applies once the slot is claimed — unclaimed slots stay - // still even though the fusion keeps running in the background. const gyroQ = (s.state === 'claimed' && s.fusion) ? s.fusion.orientation : null; if (this.overlay) this.overlay.update(gp, gyroQ); } } function labelForGamepad(gamepadIdString) { - const type = detectControllerType(gamepadIdString || ''); + const idInfo = ControllerRegistry.identifyFromGamepadId(gamepadIdString || ''); + const type = idInfo?.protocol || detectControllerType(gamepadIdString || ''); + if (type === 'steam-controller') return 'Steam Controller connected'; if (type === 'dualsense') return 'DualSense connected'; if (type === 'switch-pro') return 'Switch Pro connected'; if (type === 'xbox') return 'Xbox Controller connected'; return 'Controller connected'; } -// ── Diagnostics panel ── +// ── Dynamic view lifecycle ── +// A PLAYER panel exists iff its slot is non-empty. Reconciled each frame so it +// stays correct however the slot got claimed/released (button press, PS-hold, +// hot-plug, reconnect) without threading events through view creation. + +const slotsContainer = document.getElementById('slots'); +const slotTemplate = document.getElementById('slot-template'); +const emptyState = document.getElementById('empty-state'); +const views = new Map(); // slotId -> SlotView + +function playerNumFor(slot) { return manager.slots.indexOf(slot) + 1; } + +function ensureView(slot) { + if (views.has(slot.id)) return views.get(slot.id); + const fragment = slotTemplate.content.cloneNode(true); + const root = fragment.querySelector('.slot'); + slotsContainer.appendChild(fragment); + const view = new SlotView(slot, root, playerNumFor(slot)); + views.set(slot.id, view); + // Model + gyro: derive the profile from the controller that just claimed. + const idInfo = ControllerRegistry.identifyFromGamepadId(slot.controllerLabel || ''); + const type = idInfo?.controllerProfile || detectControllerType(slot.controllerLabel || '') || 'dualsense'; + view.initOverlay(type).catch((err) => console.error(`[${slot.id}] overlay init failed`, err)); + return view; +} + +function disposeView(slot) { + const view = views.get(slot.id); + if (!view) return; + view.dispose(); + views.delete(slot.id); +} + +function reconcileViews() { + for (const slot of manager.slots) { + const shouldShow = slot.state !== 'empty'; + if (shouldShow && !views.has(slot.id)) ensureView(slot); + else if (!shouldShow && views.has(slot.id)) disposeView(slot); + } + if (emptyState) emptyState.classList.toggle('hidden', views.size > 0); +} + +// ── AVAILABLE / PLAYER roster (forwarded to the detached List popout) ── +// +// The List is an independent always-on-top window (kind 'multi-controllers'): +// the button opens it, and rosterRows() is forwarded each frame over IPC. The +// window renders read-only — players join by pressing a button, not by clicking. + +const listToggle = document.getElementById('list-toggle'); +if (listToggle) { + listToggle.addEventListener('click', () => { + if (window.electronAPI && window.electronAPI.openHudWindow) { + window.electronAPI.openHudWindow('multi-controllers').catch(() => {}); + listToggle.classList.add('open'); + } else { + console.warn('[multi] List popout needs Electron (window.electronAPI unavailable)'); + } + }); +} + +function ctrlName(device) { + const e = ControllerRegistry.getEntry(device.vendorId, device.productId); + if (e && e.name) return e.name; + return device.productName || 'Controller'; +} +function vpStr(d) { + return `${(d.vendorId || 0).toString(16).padStart(4, '0')}:${(d.productId || 0).toString(16).padStart(4, '0')}`; +} +function synthActive(gp) { + if (!gp) return false; + for (const b of (gp.buttons || [])) if (b && (b.pressed || (b.value || 0) > 0.5)) return true; + for (const a of (gp.axes || [])) if (Math.abs(a) > 0.5) return true; + return false; +} +function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +} -function esc(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - })[c]); +// Build the roster: every claimed controller (as PLAYER n) + every pooled, +// streaming controller (as AVAILABLE). Fan-out (Steam Puck) idle sibling +// interfaces are hidden — only a streaming interface is a real controller. +function rosterRows() { + const rows = []; + for (const slot of manager.slots) { + if (slot.state === 'empty') continue; + const d = slot._hidEntry?.device || null; + rows.push({ + name: d ? ctrlName(d) : (labelForGamepad(slot.controllerLabel) || 'Controller'), + vp: d ? vpStr(d) : '—', + state: slot.state === 'orphan' ? `PLAYER ${playerNumFor(slot)} (reconnecting)` : `PLAYER ${playerNumFor(slot)}`, + active: slot._hidEntry ? synthActive(slot._hidEntry.synthetic) : false, + sort: playerNumFor(slot), + }); + } + let avail = 1000; + for (const entry of manager.presentablePoolEntries()) { // hides idle Puck siblings (core) + const d = entry.device; + rows.push({ + name: ctrlName(d), + vp: vpStr(d), + state: 'AVAILABLE', + active: synthActive(entry.synthetic), + sort: avail++, + }); + } + rows.sort((a, b) => a.sort - b.sort); + // Disambiguate identical names (two Steam bodies share name+vid:pid). + const counts = new Map(); + for (const r of rows) counts.set(r.name, (counts.get(r.name) || 0) + 1); + const seen = new Map(); + for (const r of rows) { + if (counts.get(r.name) > 1) { const n = (seen.get(r.name) || 0) + 1; seen.set(r.name, n); r.name = `${r.name} #${n}`; } + } + return rows; } -function hex4(n) { - return n != null ? '0x' + n.toString(16).padStart(4, '0') : '—'; +// Forward the roster to the detached List window each frame (throttled). main.js +// drops the message if the window isn't open, so this is a cheap no-op when the +// popout is closed. +let _listLastUpdate = 0; +function forwardRoster(now) { + if (!(window.electronAPI && window.electronAPI.sendHudState)) return; + if (now - _listLastUpdate < 200) return; // ~5Hz + _listLastUpdate = now; + try { window.electronAPI.sendHudState('multi-controllers', rosterRows()); } catch { /* window closed */ } } +// ── Diagnostics panel (per PLAYER) ── + +function hex4(n) { return n != null ? '0x' + n.toString(16).padStart(4, '0') : '—'; } + function renderSlotDiagnostics(view, pads) { if (!view.diagEl || view.diagEl.hasAttribute('hidden')) return; const now = performance.now(); - if (now - view._diagLastUpdate < 160) return; // throttle ~6Hz + if (now - view._diagLastUpdate < 160) return; view._diagLastUpdate = now; const s = view.slot; @@ -228,40 +375,23 @@ function renderSlotDiagnostics(view, pads) { const synth = s.synthetic; const hid = s.hidDevice; const driver = s.driver; - const gpInfo = gp ? ControllerRegistry.identifyFromGamepadId(gp.id) : null; - const pressedGp = gp ? [...gp.buttons].map((b, i) => b?.pressed ? i : -1).filter((i) => i >= 0) : []; const pressedSynth = synth ? synth.buttons.map((b, i) => b.pressed ? i : -1).filter((i) => i >= 0) : []; - const axesGp = gp ? [...gp.axes].map((a) => a.toFixed(2)).join(', ') : ''; const axesSynth = synth ? synth.axes.map((a) => a.toFixed(2)).join(', ') : ''; const lines = []; - lines.push(`── Gamepad API ──`); - if (gp) { - lines.push(`id ${esc(gp.id)}`); - lines.push(`mapping ${esc(gp.mapping || '(empty)')}${gp.mapping === 'standard' ? ' ' : ' non-standard'}`); - lines.push(`index ${gp.index} buttons ${gp.buttons.length} axes ${gp.axes.length}`); - lines.push(`driver? ${gpInfo ? `${esc(gpInfo.driverName)} gyro=${gpInfo.hasGyro} tp=${gpInfo.hasTouchpad}` : 'no match'}`); - lines.push(`pressed [${pressedGp.join(',')}]`); - lines.push(`axes [${esc(axesGp)}]`); - } else { - lines.push(`no pad at this slot's index (${s.gamepadIndex ?? 'none'})`); - } - - lines.push(''); lines.push(`── WebHID ──`); if (hid) { - lines.push(`product ${esc(hid.productName || '(unnamed)')}`); + lines.push(`product ${escapeHtml(hid.productName || '(unnamed)')}`); lines.push(`vid:pid ${hex4(hid.vendorId)}:${hex4(hid.productId)}`); - lines.push(`driver ${esc(driver?.entry?.name || '?')} conn ${esc(driver?.connectionType || '?')}`); - const caps = driver?.entry?.capabilities || {}; - lines.push(`caps gyro=${caps.gyro} accel=${caps.accel} touchpad=${caps.touchpad}`); + lines.push(`driver ${escapeHtml(driver?.entry?.name || '?')} conn ${escapeHtml(driver?.connectionType || '?')}`); const gyroOk = s.fusion && s.fusion._lastGyroTime > 0 && !s.fusion.calibrating; lines.push(`gyro ${gyroOk ? 'integrating' : (s.fusion?.calibrating ? 'calibrating…' : 'idle')}`); - lines.push(`synth pressed=[${pressedSynth.join(',')}] axes=[${esc(axesSynth)}]`); + lines.push(`synth pressed=[${pressedSynth.join(',')}] axes=[${escapeHtml(axesSynth)}]`); } else { lines.push(`no HID bound`); } + if (gp) lines.push(`gamepad ${escapeHtml(gp.id)}`); view.diagEl.innerHTML = lines.join('\n'); } @@ -270,80 +400,37 @@ function renderSlotDiagnostics(view, pads) { const debugEl = document.getElementById('debug-readout'); -function renderDebugStrip(pads, views) { - if (!debugEl) return; - const lines = [`pads array length: ${pads.length}`]; - let seen = 0; - for (let i = 0; i < pads.length; i++) { - const gp = pads[i]; - if (!gp) { lines.push(` [${i}] null`); continue; } - seen++; - const pressed = []; - for (let b = 0; b < gp.buttons.length; b++) { - if (gp.buttons[b]?.pressed) pressed.push(b); - } - lines.push(` [${i}] ${gp.id.slice(0, 40)} map=${gp.mapping} pressed=[${pressed.join(',')}]`); - } - if (seen === 0) lines.push(' (no gamepads visible via Gamepad API)'); - for (const v of views) { +function renderDebugStrip(pads) { + if (!debugEl || debugEl.classList.contains('hidden')) return; + const lines = [`slots claimed: ${views.size}/${MAX_PLAYERS}`]; + for (const v of views.values()) { const s = v.slot; - if (s.synthetic) { - const pressed = []; - for (let b = 0; b < s.synthetic.buttons.length; b++) { - if (s.synthetic.buttons[b].pressed) pressed.push(b); - } - lines.push(` [${s.id} HID] ${s.hidDevice?.productName || '?'} pressed=[${pressed.join(',')}]`); - } + const pressed = []; + if (s.synthetic) for (let b = 0; b < s.synthetic.buttons.length; b++) if (s.synthetic.buttons[b].pressed) pressed.push(b); + lines.push(` Player ${v.playerNum} [${s.state}] ${s.hidDevice?.productName || '?'} pressed=[${pressed.join(',')}]`); } - // Pooled HID devices (paired but not yet bound to a slot) - if (manager._hidPool.size > 0) { - for (const entry of manager._hidPool.values()) { - const pressed = []; - for (let b = 0; b < entry.synthetic.buttons.length; b++) { - if (entry.synthetic.buttons[b].pressed) pressed.push(b); - } - lines.push(` [pool] ${entry.device.productName || '?'} pressed=[${pressed.join(',')}]`); - } + lines.push(`pool (available): ${manager._hidPool.size}`); + for (const entry of manager._hidPool.values()) { + const streaming = entry.hidActiveSince > 0; + const fan = entry.driver?.constructor?.needsSiblingFanout ? ' fanout' : ''; + lines.push(` [pool] ${entry.device.productName || '?'} ${vpStr(entry.device)}${fan} streaming=${streaming}`); } - lines.push(`slots: ${views.map((v) => `${v.slot.id}=${v.slot.state}`).join(' ')}`); + const seen = pads.filter(Boolean).length; + lines.push(`gamepad-api pads: ${seen}`); debugEl.textContent = lines.join('\n'); } // ── Wiring ── -const slotsContainer = document.getElementById('slots'); -const slotTemplate = document.getElementById('slot-template'); - -const views = manager.slots.map((slot) => { - const fragment = slotTemplate.content.cloneNode(true); - const root = fragment.querySelector('.slot'); - slotsContainer.appendChild(fragment); - return new SlotView(slot, root); -}); - async function boot() { - for (const v of views) { - try { - await v.initOverlay('dualsense'); - } catch (err) { - console.error(`[${v.slot.id}] overlay init failed`, err); - } - } - window.addEventListener('resize', () => views.forEach((v) => v._resize())); - window.addEventListener('gamepadconnected', (e) => { - console.log('gamepadconnected', e.gamepad.index, e.gamepad.id); - }); - window.addEventListener('gamepaddisconnected', (e) => { - console.log('gamepaddisconnected', e.gamepad.index, e.gamepad.id); - }); if (navigator.hid) { - // Already-approved controllers pool gesture-free; new ones pair via the - // per-slot Connect button (requestDevice needs a user gesture); hot-plug - // of approved devices is handled by wireHidHotplug. + // WebHID-first: pool every approved, known device (gate-free). Hot-plug of + // approved devices + the periodic rescan keep the pool fresh. await manager.autoPoolApprovedHid(); manager.wireHidHotplug(); + setInterval(() => { manager.autoPoolApprovedHid().catch(() => {}); }, RESCAN_MS); } loop(); @@ -354,13 +441,15 @@ function loop() { const pads = (navigator.getGamepads && navigator.getGamepads()) || []; manager.ingestFrame(pads, now); + reconcileViews(); - for (const v of views) { + for (const v of views.values()) { v.tick(pads); renderSlotDiagnostics(v, pads); } - renderDebugStrip(pads, views); + forwardRoster(now); + renderDebugStrip(pads); requestAnimationFrame(loop); } diff --git a/apps/overlay/src/js/multi-controllers-window.js b/apps/overlay/src/js/multi-controllers-window.js new file mode 100644 index 0000000..d7119c4 --- /dev/null +++ b/apps/overlay/src/js/multi-controllers-window.js @@ -0,0 +1,54 @@ +// ============================================================ +// MULTI-CONTROLLERS WINDOW — detached players & controllers roster +// ============================================================ +// +// Display-only popout for the multi app. multi-app.js forwards the roster rows +// each frame over IPC ('hud-state-update', kind 'multi-controllers'); this window +// renders them: claimed controllers as PLAYER n, pooled streaming controllers as +// AVAILABLE. Window chrome (drag / close) is handled by hud-window-chrome.js. +// +// Row shape (from multi-app.js rosterRows): { name, vp, state, active } +// state: 'AVAILABLE' | 'PLAYER n' | 'PLAYER n (reconnecting)' + +const listEl = document.getElementById('list'); +const countEl = document.getElementById('count'); + +const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +function tagClass(state) { + if (state.includes('reconnecting')) return 'reconnecting'; + if (state.startsWith('PLAYER')) return 'player'; + return 'available'; +} + +let sig = ''; +function render(rows) { + if (!Array.isArray(rows)) return; + const s = rows.map((r) => r.name + '|' + r.state + '|' + r.vp).join('~'); + if (s !== sig) { + sig = s; + listEl.innerHTML = rows.length ? rows.map((r, i) => { + const tc = tagClass(r.state); + const dotCls = r.active ? 'on' : (tc === 'player' ? 'player' : ''); + return `
` + + `` + + `${esc(r.name)}` + + `${esc(r.vp)}` + + `${esc(r.state)}
`; + }).join('') : '
No controllers connected.
Power one on.
'; + if (countEl) countEl.textContent = rows.length ? `(${rows.length})` : ''; + } + // Live dot state updates without a full re-render. + for (let i = 0; i < rows.length; i++) { + const d = listEl.querySelector(`[data-dot="${i}"]`); + if (d) { + const tc = tagClass(rows[i].state); + d.classList.toggle('on', !!rows[i].active); + d.classList.toggle('player', !rows[i].active && tc === 'player'); + } + } +} + +if (window.electronAPI && window.electronAPI.onHudState) { + window.electronAPI.onHudState(render); +} diff --git a/apps/overlay/src/lobby.html b/apps/overlay/src/lobby.html index 17fb034..3c5d356 100644 --- a/apps/overlay/src/lobby.html +++ b/apps/overlay/src/lobby.html @@ -118,9 +118,11 @@ #ctrl-count { font: 11px/1 var(--mono); color: #8a93a6; letter-spacing: .3px; } .pairbtn-sm { font: 11px/1 var(--mono); color: #cdd6e6; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.18); border-radius: 8px; padding: 7px 11px; cursor: pointer; } .pairbtn-sm:hover { color: #fff; border-color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.12); } + .pairbtn-close { font: 15px/1 var(--mono); color: #cdd6e6; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.18); border-radius: 8px; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer; } + .pairbtn-close:hover { color: #fff; background: rgba(230,70,70,0.85); border-color: rgba(230,70,70,0.9); } /* controllers panel */ - #ctrl-panel { position: fixed; top: 44px; right: 12px; z-index: 101; width: 300px; max-width: 82vw; background: rgba(9,13,11,0.97); border: 1px solid rgba(255,255,255,0.14); border-radius: 12px; padding: 11px 12px; box-shadow: 0 20px 44px -18px #000; -webkit-app-region: no-drag; } + #ctrl-panel { position: fixed; top: 44px; right: 12px; z-index: 101; width: 380px; max-width: 88vw; background: rgba(9,13,11,0.97); border: 1px solid rgba(255,255,255,0.14); border-radius: 12px; padding: 11px 12px; box-shadow: 0 20px 44px -18px #000; -webkit-app-region: no-drag; } #ctrl-panel[hidden] { display: none; } .cp-head { font: 700 10px/1 var(--mono); letter-spacing: 1.5px; color: #9fb0c6; margin-bottom: 9px; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } #cp-note { font-weight: 400; color: #7d879a; letter-spacing: .3px; } @@ -155,6 +157,7 @@ no controllers +