diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index f8a63c3..bae84f5 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -641,6 +641,64 @@ 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; } + + /* 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; } + + /* 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, @@ -947,6 +1005,11 @@

Controller Overlay

+
+ + +
+
@@ -1297,6 +1360,14 @@

No gamepad
+ + + + + +
P @@ -1388,6 +1459,8 @@

+
No controller selected
+
Press a controller, or open the list to pick one.
diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 953ab84..24fd229 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -50,6 +50,17 @@ 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'; +// 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'); @@ -189,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 @@ -684,6 +713,11 @@ async function init() { await initControllerList(); initSerialInventory(); + // Start the last-used grace window (#104) now that the pool is populating. + // 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); // Adopt an already-connected XInput pad at startup (Xbox has no HID interface, @@ -812,6 +846,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) @@ -894,6 +932,34 @@ 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; // 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; } +} +let _lastUsed = readLastUsed(); +let _lastUsedDeadline = 0; // set in init: performance.now() + grace +let _lastUsedFallbackDone = false; +function rememberLastUsed(device, connType) { + if (!device) return; + 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 */ } +} + // 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 +984,48 @@ 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 (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 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 })) }; + 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] nothing selected after grace — opening the controllers list'); + try { window.electronAPI?.openHudWindow?.('controllers', currentControllerType, { on: false }); } catch { /* no-op */ } + } +} + // ── Gamepad events ── window.addEventListener('gamepadconnected', (e) => { @@ -1113,6 +1221,27 @@ 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'); + } +} + +// 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; @@ -1137,6 +1266,9 @@ function designateEntry(entry) { if (isPuckDevice(hidDevice)) onPuckConnected(); else onPuckDisconnected(); maybeSwapProfileAfterImuProbe(); + rememberLastUsed(entry.device, entry.driver?.connectionType); // #104: this pad becomes the launch default + updateLiveBadge(); + updatePreviewLabel(); } // Stop showing the current controller (device stays pooled + live). Used on @@ -1151,6 +1283,8 @@ function undesignateEntry() { gyroActive = false; gyroPermitted = false; onPuckDisconnected(); + updateLiveBadge(); + updatePreviewLabel(); } /** @@ -1360,7 +1494,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(); @@ -2189,38 +2327,61 @@ 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. - 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