From ba29bf0c787f3397a9984cf8c98f4f63520ed99c Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 19:05:11 -0400 Subject: [PATCH 1/8] overlay(#104): add 'No controller selected' text under the splash emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare gamepad emoji under-communicated the not-SELECTED state. Add a title ('No controller selected') + hint ('Press a controller, or open the list ⧉ to pick one.') beneath it, so SELECTED vs not-SELECTED reads clearly. --- apps/overlay/src/index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index f8a63c3..8dce20a 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -641,6 +641,25 @@ fill: none; opacity: 0.85; } + /* Text under the No-Controller emoji — makes the not-SELECTED state explicit + (the emoji alone under-communicated it). See #104. */ + #no-controller .splash-title { + margin-top: 1.5rem; + font-size: 1.5rem; + font-weight: 700; + color: #e8e8ee; + letter-spacing: 0.02em; + } + #no-controller .splash-sub { + margin-top: 0.5rem; + max-width: 30rem; + padding: 0 2rem; + text-align: center; + font-size: 0.95rem; + line-height: 1.4; + color: #b0b0bc; + } + #no-controller .splash-list-glyph { opacity: 0.9; } /* ── Button HUD — compact 2D readout of gamepad state ── */ /* Renders face buttons, dpad, shoulders, analog triggers, stick positions, @@ -1388,6 +1407,8 @@

+
No controller selected
+
Press a controller, or open the list to pick one.
From 6ce385d1206c1e2db78a221533175b5c7a7a1023 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 19:05:11 -0400 Subject: [PATCH 2/8] overlay(#104): default to the last-used controller on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persist the last SELECTED pad's identity (vid:pid) in designateEntry (overlay:lastController). - On launch, within a 6s grace window, auto-SELECT the remembered pad as soon as it's RECEIVING — no button press required (relaxes the input-driven policy ONLY for the remembered pad). A real press on any pad still wins: press-based autoAdoptFromPool runs first each loop and sets hidDevice before tryAdoptLastUsed. - If the remembered pad never shows within the window, open the controllers window (AVAILABLE list) so the user can pick; No-Controller splash stays. NEEDS HARDWARE VERIFY (grace/enumeration timing, BT pads). vid:pid identity is the browser-safe baseline; per-unit Electron serial matching is a follow-up. --- apps/overlay/src/js/app.js | 58 +++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 953ab84..fb79747 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -684,6 +684,9 @@ async function init() { await initControllerList(); initSerialInventory(); + // Start the last-used grace window (#104) now that the pool is populating. + _lastUsedDeadline = performance.now() + LAST_USED_GRACE_MS; + requestAnimationFrame(loop); // Adopt an already-connected XInput pad at startup (Xbox has no HID interface, @@ -894,6 +897,29 @@ function onGamepadDisconnected(index) { overlay.setVisible(false); } +// ── Last-used controller default (issue #104) ───────────────────────────── +// Persist the last SELECTED pad's identity so the next launch can auto-select +// it. Baseline identity is vid:pid (the browser can't see a per-unit serial — +// an Electron serial refinement is a follow-up). On launch the remembered pad +// gets a grace window to enumerate (BT/wireless are slow) before we fall back +// to opening the AVAILABLE list. +const LAST_USED_GRACE_MS = 6000; +function readLastUsed() { + try { return JSON.parse(localStorage.getItem('overlay:lastController') || 'null'); } + catch { return null; } +} +let _lastUsed = readLastUsed(); +let _lastUsedDeadline = 0; // set in init: performance.now() + grace +let _lastUsedFallbackDone = false; +function rememberLastUsed(device) { + if (!device) return; + _lastUsed = { v: device.vendorId, p: device.productId, name: device.productName || '' }; + try { localStorage.setItem('overlay:lastController', JSON.stringify(_lastUsed)); } catch { /* ignore */ } +} +function matchesLastUsed(device) { + return !!(_lastUsed && device && device.vendorId === _lastUsed.v && device.productId === _lastUsed.p); +} + // Input-driven auto-adopt from the WebHID pool: when NOTHING is SELECTED, // designate the controller the user has actually ENGAGED (a pool entry that has // been pressed), else the first receiving gyro-capable entry. This is the ONLY @@ -918,6 +944,31 @@ function autoAdoptFromPool() { switchController(stub, d); } +// Launch default (issue #104): within a grace window after boot, auto-SELECT the +// last-used controller as soon as it's RECEIVING — no button press required +// (this relaxes the input-driven policy ONLY for the remembered pad). A real +// press on any pad still wins, because press-based autoAdoptFromPool runs first +// in the loop and sets hidDevice before this. After the window, if the pad never +// showed, open the AVAILABLE list so the user can pick. +function tryAdoptLastUsed(now) { + if (hidDevice || _preferredGyroDevice || switchingController) return; + if (!_lastUsed) { _lastUsedFallbackDone = true; return; } // first-ever run: nothing to restore + if (now <= _lastUsedDeadline) { + const entry = [...listManager._hidPool.values()].find( + (e) => e.hidActiveSince > 0 && matchesLastUsed(e.device)); + if (!entry) return; // keep waiting within the window + const d = entry.device, hx = (n) => n.toString(16).padStart(4, '0'); + const stub = { id: `${d.productName || 'Controller'} (STANDARD GAMEPAD Vendor: ${hx(d.vendorId)} Product: ${hx(d.productId)})`, + index: -1, axes: [0, 0, 0, 0], buttons: Array.from({ length: 22 }, () => ({ pressed: false, value: 0 })) }; + console.log('[last-used] auto-selecting remembered controller', d.productName || `${hx(d.vendorId)}:${hx(d.productId)}`); + switchController(stub, d); + } else if (!_lastUsedFallbackDone) { + _lastUsedFallbackDone = true; + console.log('[last-used] remembered controller not available after grace — opening the list'); + try { window.electronAPI?.openHudWindow?.('controllers', currentControllerType, { on: false }); } catch { /* no-op */ } + } +} + // ── Gamepad events ── window.addEventListener('gamepadconnected', (e) => { @@ -1137,6 +1188,7 @@ function designateEntry(entry) { if (isPuckDevice(hidDevice)) onPuckConnected(); else onPuckDisconnected(); maybeSwapProfileAfterImuProbe(); + rememberLastUsed(entry.device); // #104: this pad becomes the launch default } // Stop showing the current controller (device stays pooled + live). Used on @@ -1360,7 +1412,11 @@ function loop() { if (_now - _streamThrottle > 500) { _streamThrottle = _now; checkStalledStream(_now); } // Auto-adopt a HID controller from the pool when nothing is SELECTED (startup / // after a disconnect). Cheap: bails immediately once something is selected. - if (!hidDevice && _now - _adoptThrottle > 200) { _adoptThrottle = _now; autoAdoptFromPool(); } + if (!hidDevice && _now - _adoptThrottle > 200) { + _adoptThrottle = _now; + autoAdoptFromPool(); // press-based (a real press wins) + if (!hidDevice) tryAdoptLastUsed(_now); // else restore the last-used pad within grace (#104) + } if (!modelReady) return; const gamepad = readGamepad(); From ebc4d9cf710d93db12840f1ebcd6ce0b0f42f5f6 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 20:01:16 -0400 Subject: [PATCH 3/8] overlay(#104): open the controllers list after grace even with no remembered pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback bug: the first launch after this feature ships has nothing persisted (_lastUsed is null), and the early-out for that case skipped the fallback entirely — so the list never opened when nothing was selected. Move the no-remembered-pad check inside the grace branch (just wait it out) so the 'grace expired + nothing SELECTED -> open the AVAILABLE list' path fires regardless of whether a pad was remembered. (RESET DEFAULTS already clears overlay:lastController via its overlay* key sweep + reload — no change needed there.) --- apps/overlay/src/js/app.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index fb79747..fc535cd 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -952,19 +952,24 @@ function autoAdoptFromPool() { // showed, open the AVAILABLE list so the user can pick. function tryAdoptLastUsed(now) { if (hidDevice || _preferredGyroDevice || switchingController) return; - if (!_lastUsed) { _lastUsedFallbackDone = true; return; } // first-ever run: nothing to restore if (now <= _lastUsedDeadline) { + // Within the grace window: adopt the remembered pad the moment it's receiving. + // No remembered pad yet (first run) → nothing to match; keep waiting out the + // window (which also lets the user press a controller to select it first). + if (!_lastUsed) return; const entry = [...listManager._hidPool.values()].find( (e) => e.hidActiveSince > 0 && matchesLastUsed(e.device)); - if (!entry) return; // keep waiting within the window + if (!entry) return; // remembered pad not up yet — keep waiting const d = entry.device, hx = (n) => n.toString(16).padStart(4, '0'); const stub = { id: `${d.productName || 'Controller'} (STANDARD GAMEPAD Vendor: ${hx(d.vendorId)} Product: ${hx(d.productId)})`, index: -1, axes: [0, 0, 0, 0], buttons: Array.from({ length: 22 }, () => ({ pressed: false, value: 0 })) }; console.log('[last-used] auto-selecting remembered controller', d.productName || `${hx(d.vendorId)}:${hx(d.productId)}`); switchController(stub, d); } else if (!_lastUsedFallbackDone) { + // Grace expired with nothing SELECTED → open the AVAILABLE list so the user can + // pick, whether or not a pad was remembered. The No-Controller splash stays. _lastUsedFallbackDone = true; - console.log('[last-used] remembered controller not available after grace — opening the list'); + console.log('[last-used] nothing selected after grace — opening the controllers list'); try { window.electronAPI?.openHudWindow?.('controllers', currentControllerType, { on: false }); } catch { /* no-op */ } } } From 8bcab175948330d725c29db0b57716e4b96a01b2 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 20:04:43 -0400 Subject: [PATCH 4/8] overlay(#104): shorter grace window when no pad is remembered (2s vs 6s) With nothing remembered there's nothing to wait for, so open the AVAILABLE list sooner (NO_PAD_GRACE_MS = 2s). The full 6s wait now applies only when we're actually waiting for a remembered pad to enumerate (BT/wireless is slow). The 2s beat still lets the user press a connected pad to select it first, and lets the pool populate so the list isn't empty when it opens. --- apps/overlay/src/js/app.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index fc535cd..7471859 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -685,7 +685,9 @@ async function init() { initSerialInventory(); // Start the last-used grace window (#104) now that the pool is populating. - _lastUsedDeadline = performance.now() + LAST_USED_GRACE_MS; + // Full wait only when we're actually waiting for a REMEMBERED pad to enumerate; + // with nothing remembered there's nothing to wait for, so open the list sooner. + _lastUsedDeadline = performance.now() + (_lastUsed ? LAST_USED_GRACE_MS : NO_PAD_GRACE_MS); requestAnimationFrame(loop); @@ -903,7 +905,8 @@ function onGamepadDisconnected(index) { // an Electron serial refinement is a follow-up). On launch the remembered pad // gets a grace window to enumerate (BT/wireless are slow) before we fall back // to opening the AVAILABLE list. -const LAST_USED_GRACE_MS = 6000; +const LAST_USED_GRACE_MS = 6000; // wait for a REMEMBERED pad to enumerate (BT/wireless is slow) +const NO_PAD_GRACE_MS = 2000; // nothing remembered: a brief beat to press a pad, else open the list function readLastUsed() { try { return JSON.parse(localStorage.getItem('overlay:lastController') || 'null'); } catch { return null; } From a15f4f8239b087c554582521c02a408179db1f34 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 20:31:36 -0400 Subject: [PATCH 5/8] overlay(#104): opt-in LIVE controller badge (default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small '● ' badge shown while a controller is SELECTED and driving the overlay, gated behind a new 'Show Controller Badge' setting that is OFF by default (overlay:showBadge). updateLiveBadge() reflects designate/undesignate + the toggle. --- apps/overlay/src/index.html | 31 +++++++++++++++++++++++++++++++ apps/overlay/src/js/app.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index 8dce20a..02749f3 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -661,6 +661,28 @@ } #no-controller .splash-list-glyph { opacity: 0.9; } + /* LIVE badge (#104) — opt-in "this controller is driving the overlay". */ + #live-badge { + position: fixed; + top: 12px; left: 50%; + transform: translateX(-50%); + z-index: 55; + display: flex; + align-items: center; + gap: 0.4em; + padding: 4px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); + color: #e8e8ee; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + pointer-events: none; + -webkit-app-region: no-drag; + } + #live-badge.hidden { display: none; } + #live-badge .live-dot { color: #33dd77; font-size: 0.7em; } + /* ── Button HUD — compact 2D readout of gamepad state ── */ /* Renders face buttons, dpad, shoulders, analog triggers, stick positions, and system buttons. Each element flips between 'idle' and 'pressed' via @@ -966,6 +988,11 @@

Controller Overlay

+
+ + +
+
@@ -1316,6 +1343,10 @@

No gamepad
+ + +
P diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 7471859..ec74fee 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -50,6 +50,12 @@ const gamepadStatusEl = document.getElementById('gamepad-status'); const gyroToggleBtn = document.getElementById('gyro-toggle'); const clickThroughIndicator = document.getElementById('click-through-indicator'); const noControllerSplash = document.getElementById('no-controller'); +// LIVE badge (#104): opt-in indicator showing the SELECTED controller's name +// while it drives the overlay. Default OFF — only shown when the user enables +// "Show Controller Badge" in settings. +const liveBadge = document.getElementById('live-badge'); +const liveBadgeName = document.getElementById('live-badge-name'); +let showBadge = localStorage.getItem('overlay:showBadge') === '1'; const puckHint = document.getElementById('puck-hint'); const puckStatusBanner = document.getElementById('puck-status-banner'); const puckStatusDismiss = document.getElementById('puck-status-dismiss'); @@ -1172,6 +1178,19 @@ async function finishGyroConnect(device) { // Point the overlay's viz at a pool entry: alias hidDevice/controllerDriver/ // syntheticGamepad/gyroFusion to it, apply the overlay's gravity/yaw prefs to // its fusion, and run the post-connect hooks. The manager keeps feeding it. +// Show/hide the opt-in LIVE badge (#104): visible only when a controller is +// SELECTED (hidDevice set) AND the user has enabled the badge setting. +function updateLiveBadge() { + if (!liveBadge) return; + if (showBadge && hidDevice) { + const vp = { vendorId: hidDevice.vendorId, productId: hidDevice.productId }; + liveBadgeName.textContent = _ctrlName(vp, hidDevice.productName || 'Controller'); + liveBadge.classList.remove('hidden'); + } else { + liveBadge.classList.add('hidden'); + } +} + function designateEntry(entry) { selectedEntry = entry; hidDevice = entry.device; @@ -1197,6 +1216,7 @@ function designateEntry(entry) { else onPuckDisconnected(); maybeSwapProfileAfterImuProbe(); rememberLastUsed(entry.device); // #104: this pad becomes the launch default + updateLiveBadge(); } // Stop showing the current controller (device stays pooled + live). Used on @@ -1211,6 +1231,7 @@ function undesignateEntry() { gyroActive = false; gyroPermitted = false; onPuckDisconnected(); + updateLiveBadge(); } /** @@ -2253,6 +2274,17 @@ window.addEventListener('mousedown', (e) => { setSettingsVisible(false, 'click-outside'); }); +// LIVE badge toggle (#104) — default OFF; persists in localStorage. +const badgeToggle = document.getElementById('badge-toggle'); +if (badgeToggle) { + badgeToggle.checked = showBadge; + badgeToggle.addEventListener('change', (e) => { + showBadge = e.target.checked; + localStorage.setItem('overlay:showBadge', showBadge ? '1' : '0'); + updateLiveBadge(); + }); +} + controllerTypeSelect.addEventListener('change', async (e) => { // Persist the user's manual choice so it survives a relaunch — without // this, every restart would revert to 'auto' and undo their preference. From 908f756ba449d31d2ff2a5252af8b39ac9052542 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 20:36:00 -0400 Subject: [PATCH 6/8] overlay(#104): PREVIEW label + snap-to-Auto-Detect on select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Picking a model from the dropdown while nothing is SELECTED now shows a 'Preview · not selected' label (the viewport doubles as a model browser). - Such a preview pick is NO LONGER persisted (was becoming a sticky override); only a deliberate override (chosen while a controller is live) or an explicit Auto-Detect persists. - When a real controller takes over, switchController resets the dropdown to Auto-Detect and snaps to that controller's own model (discards the preview). - Choosing Auto-Detect with nothing live returns to the IDLE splash. --- apps/overlay/src/index.html | 21 +++++++++++ apps/overlay/src/js/app.js | 75 ++++++++++++++++++++++++++----------- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index 02749f3..bae84f5 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -683,6 +683,23 @@ #live-badge.hidden { display: none; } #live-badge .live-dot { color: #33dd77; font-size: 0.7em; } + /* PREVIEW label (#104) — shown while browsing a model with nothing selected. */ + #preview-label { + position: fixed; + bottom: 14px; left: 50%; + transform: translateX(-50%); + z-index: 55; + padding: 4px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.5); + color: #c9c9d2; + font-size: 0.8rem; + letter-spacing: 0.03em; + pointer-events: none; + -webkit-app-region: no-drag; + } + #preview-label.hidden { display: none; } + /* ── Button HUD — compact 2D readout of gamepad state ── */ /* Renders face buttons, dpad, shoulders, analog triggers, stick positions, and system buttons. Each element flips between 'idle' and 'pressed' via @@ -1347,6 +1364,10 @@

driving the overlay. Hidden by default (setting: Show Controller Badge). --> + + +
P diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index ec74fee..20e0efd 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -56,6 +56,11 @@ const noControllerSplash = document.getElementById('no-controller'); const liveBadge = document.getElementById('live-badge'); const liveBadgeName = document.getElementById('live-badge-name'); let showBadge = localStorage.getItem('overlay:showBadge') === '1'; +// PREVIEW state (#104): a model picked from the dropdown while nothing is +// SELECTED is a temporary browse-preview — not persisted, and reset to +// Auto-Detect once a real controller takes over. +const previewLabel = document.getElementById('preview-label'); +let _previewPick = false; const puckHint = document.getElementById('puck-hint'); const puckStatusBanner = document.getElementById('puck-status-banner'); const puckStatusDismiss = document.getElementById('puck-status-dismiss'); @@ -823,6 +828,10 @@ async function switchController(gamepad, preferredDevice = null) { switchingController = true; _preferredGyroDevice = preferredDevice; // bind THIS exact handle if given (list-selection) + // #104: a model picked while nothing was selected is a temporary PREVIEW — a + // real controller now taking over shows ITS model, so drop back to Auto-Detect. + if (_previewPick) { controllerTypeSelect.value = 'auto'; _previewPick = false; } + try { const newType = controllerTypeSelect.value === 'auto' ? pickControllerProfile(gamepad.id) @@ -1191,6 +1200,14 @@ function updateLiveBadge() { } } +// Show the "Preview · not selected" label while browsing a model with nothing +// SELECTED (dropdown on a specific model, no live controller). #104. +function updatePreviewLabel() { + if (!previewLabel) return; + const previewing = !hidDevice && controllerTypeSelect.value !== 'auto'; + previewLabel.classList.toggle('hidden', !previewing); +} + function designateEntry(entry) { selectedEntry = entry; hidDevice = entry.device; @@ -1217,6 +1234,7 @@ function designateEntry(entry) { maybeSwapProfileAfterImuProbe(); rememberLastUsed(entry.device); // #104: this pad becomes the launch default updateLiveBadge(); + updatePreviewLabel(); } // Stop showing the current controller (device stays pooled + live). Used on @@ -1232,6 +1250,7 @@ function undesignateEntry() { gyroPermitted = false; onPuckDisconnected(); updateLiveBadge(); + updatePreviewLabel(); } /** @@ -2286,37 +2305,49 @@ if (badgeToggle) { } controllerTypeSelect.addEventListener('change', async (e) => { - // Persist the user's manual choice so it survives a relaunch — without - // this, every restart would revert to 'auto' and undo their preference. - localStorage.setItem('overlay:controllerType', e.target.value); + const val = e.target.value; + // #104: persist ONLY a deliberate override (chosen while a controller is + // SELECTED) or an explicit Auto-Detect. A model picked with nothing selected + // is a temporary browse-PREVIEW — don't persist it (so it never becomes a + // sticky default) and flag it so a real controller resets back to Auto-Detect. + if (hidDevice || val === 'auto') { + localStorage.setItem('overlay:controllerType', val); + _previewPick = false; + } else { + _previewPick = true; + } const gp = gamepadIndex !== null ? navigator.getGamepads()[gamepadIndex] : null; - if (e.target.value === 'auto' && gp) { - const type = pickControllerProfile(gp.id); - if (type !== currentControllerType) { - modelReady = false; - currentControllerType = type; - applyHudLabels(type); - await overlay.setControllerType(type); - modelReady = true; + if (val === 'auto') { + if (gp) { + const type = pickControllerProfile(gp.id); + if (type !== currentControllerType) { + modelReady = false; + currentControllerType = type; + applyHudLabels(type); + await overlay.setControllerType(type); + modelReady = true; + } } - } else if (e.target.value !== 'auto') { - if (e.target.value !== currentControllerType) { + // Auto-Detect with nothing live → back to the IDLE splash (stop previewing). + if (!hidDevice) { + overlay.setVisible(false); + noControllerSplash.classList.remove('hidden'); + } + } else { + if (val !== currentControllerType) { modelReady = false; - currentControllerType = e.target.value; - applyHudLabels(e.target.value); - await overlay.setControllerType(e.target.value); + currentControllerType = val; + applyHudLabels(val); + await overlay.setControllerType(val); modelReady = true; } - // Even when the type didn't change (e.g. user picks the profile the - // overlay already loaded by default), force the model visible and - // hide the no-controller splash. Manually picking a profile is an - // explicit "I want to see this model" — useful for previewing a - // controller's GLB without one actually connected (e.g. Steam - // Controller, which Chromium's Gamepad API can't enumerate at all). + // Manually picking a profile is an explicit "show me this model" — visible + + // splash hidden. With nothing selected this is a browse-PREVIEW (labeled). overlay.setVisible(true); noControllerSplash.classList.add('hidden'); } + updatePreviewLabel(); }); // Settings panel gyro button also connects From 113073c9e4ae19a03dfe1a36a7b43634040e34bb Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 20:56:08 -0400 Subject: [PATCH 7/8] overlay(#104): per-unit last-used matching (serial + transport, not just vid:pid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two same-model pads are connected (e.g. a BT GameSir and a USB DS4, both 054c:05c4), vid:pid alone could auto-select the wrong one. rememberLastUsed now also stores the unit's transport (driver.connectionType) and its unit-specific serial when resolvable (real MAC/serial, not a '(no serial)' placeholder). tryAdoptLastUsed ranks same-vid:pid receiving pads: exact serial match first (once the main-process inventory is populated via a list/pair gesture), else same transport (works at boot — distinguishes BT vs USB), else any same-model pad. Old records without conn/serial fall back to vid:pid — no regression. --- apps/overlay/src/js/app.js | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 20e0efd..0a86225 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -929,14 +929,18 @@ function readLastUsed() { let _lastUsed = readLastUsed(); let _lastUsedDeadline = 0; // set in init: performance.now() + grace let _lastUsedFallbackDone = false; -function rememberLastUsed(device) { +function rememberLastUsed(device, connType) { if (!device) return; - _lastUsed = { v: device.vendorId, p: device.productId, name: device.productName || '' }; + const vp = { vendorId: device.vendorId, productId: device.productId }; + const s = serialForDevice(vp, connType); + // Keep a serial only when it's unit-specific (a real MAC/serial) — drop the + // '(no serial)' / '(USB — no serial)' / 'a / b' placeholders that don't pin one + // unit. Transport (conn) is ALWAYS kept: it distinguishes two same-model pads on + // different buses (BT clone vs USB DS4) even before any serial scan has run. + const serial = (s && !s.startsWith('(') && !s.includes(' / ')) ? s : null; + _lastUsed = { v: device.vendorId, p: device.productId, name: device.productName || '', conn: connType || null, serial }; try { localStorage.setItem('overlay:lastController', JSON.stringify(_lastUsed)); } catch { /* ignore */ } } -function matchesLastUsed(device) { - return !!(_lastUsed && device && device.vendorId === _lastUsed.v && device.productId === _lastUsed.p); -} // Input-driven auto-adopt from the WebHID pool: when NOTHING is SELECTED, // designate the controller the user has actually ENGAGED (a pool entry that has @@ -975,9 +979,21 @@ function tryAdoptLastUsed(now) { // No remembered pad yet (first run) → nothing to match; keep waiting out the // window (which also lets the user press a controller to select it first). if (!_lastUsed) return; - const entry = [...listManager._hidPool.values()].find( - (e) => e.hidActiveSince > 0 && matchesLastUsed(e.device)); - if (!entry) return; // remembered pad not up yet — keep waiting + const connOf = (e) => (e.driver && e.driver.connectionType) || 'hid'; + const vpMatches = [...listManager._hidPool.values()].filter( + (e) => e.hidActiveSince > 0 && e.device.vendorId === _lastUsed.v && e.device.productId === _lastUsed.p); + if (!vpMatches.length) return; // remembered pad not up yet — keep waiting + // Pick the exact UNIT among same-model pads: prefer a serial match (needs the + // main-process inventory, populated after a list/pair gesture), else the same + // transport (distinguishes a BT clone from a USB DS4 on the same vid:pid), + // else any same-model receiving pad. + let entry = null; + if (_lastUsed.serial) { + entry = vpMatches.find((e) => serialForDevice( + { vendorId: e.device.vendorId, productId: e.device.productId }, connOf(e)) === _lastUsed.serial); + } + if (!entry && _lastUsed.conn) entry = vpMatches.find((e) => connOf(e) === _lastUsed.conn); + if (!entry) entry = vpMatches[0]; const d = entry.device, hx = (n) => n.toString(16).padStart(4, '0'); const stub = { id: `${d.productName || 'Controller'} (STANDARD GAMEPAD Vendor: ${hx(d.vendorId)} Product: ${hx(d.productId)})`, index: -1, axes: [0, 0, 0, 0], buttons: Array.from({ length: 22 }, () => ({ pressed: false, value: 0 })) }; @@ -1232,7 +1248,7 @@ function designateEntry(entry) { if (isPuckDevice(hidDevice)) onPuckConnected(); else onPuckDisconnected(); maybeSwapProfileAfterImuProbe(); - rememberLastUsed(entry.device); // #104: this pad becomes the launch default + rememberLastUsed(entry.device, entry.driver?.connectionType); // #104: this pad becomes the launch default updateLiveBadge(); updatePreviewLabel(); } From 67a49af03bd6687e77c82c8934eb690a1590449e Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 21:09:48 -0400 Subject: [PATCH 8/8] overlay(#104): populate serials for the auto-opened list (first-gesture scan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #104 auto-opened controllers window had no user gesture, so the serial scan (requestDevice, which needs user activation) never ran — its rows showed no serials while manually clicking 'Show list…' did. Arm a one-shot scan on the user's first interaction with the overlay (pointerdown/keydown); Electron auto-picks with no chooser and upserts every present device's serial into the live inventory the window reads. The manual 'Show list…' scan is unchanged. --- apps/overlay/src/js/app.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 0a86225..24fd229 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -200,10 +200,28 @@ function initSerialInventory() { try { if (window.electronAPI.listHidControllers) window.electronAPI.listHidControllers().then((l) => { if (Array.isArray(l)) _hidInventory = l; }).catch(() => {}); if (window.electronAPI.onHidControllersSnapshot) window.electronAPI.onHidControllersSnapshot((l) => { if (Array.isArray(l)) _hidInventory = l; }); - // Serial scan happens on the "Show list…" gesture — requestDevice needs user - // activation, so a boot timer can't populate the inventory. + // A serial scan needs a user gesture (requestDevice). The "Show list…" button + // scans on click, but the #104 auto-opened list has NO gesture — so its rows + // showed no serials. Arm a one-shot scan on the user's first interaction with + // the overlay: Electron auto-picks (no chooser) and upserts every present + // device's serial into the inventory the (live) window reads. + armFirstGestureSerialScan(); } catch (e) { /* best-effort */ } } + +let _serialScanArmed = false; +function armFirstGestureSerialScan() { + if (_serialScanArmed) return; + if (!(navigator.hid && navigator.userAgent.includes('Electron'))) return; + _serialScanArmed = true; + const scan = () => { + document.removeEventListener('pointerdown', scan, true); + document.removeEventListener('keydown', scan, true); + try { navigator.hid.requestDevice({ filters: ControllerRegistry.getHIDFilters() }).catch(() => {}); } catch (e) { /* no-op */ } + }; + document.addEventListener('pointerdown', scan, true); + document.addEventListener('keydown', scan, true); +} // Match a handle to its main-process serial. Electron's main HIDDevice exposes // no `collections` (so a descriptor fingerprint is impossible there) — but the // renderer DOES know each handle's connection type. Unique vid:pid → 1:1. Two