feedBack/static/v3/gamepad.js
Matthew Harris Glover 23c509322b
Add gamepad/controller support (#1001)
* feat(input): add gamepad/controller support

Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.

- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
  synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
  element (falling back to document), reusing the app's existing
  keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
  player shortcuts, text-field/modal guards) instead of a parallel
  action-mapping table. Only acts on gamepads reporting the W3C
  "standard" mapping — which is what Steam Input presents for the
  Deck's built-in controls, both in Gaming Mode and in Desktop Mode
  via a non-Steam shortcut — so button order is guaranteed correct
  and a non-standard/raw device safely no-ops instead of misfiring.
  Handles Steam Input's virtual-pad duplicates (a real controller
  plus 1-2 mirrored XInput slots) without spamming connect toasts or
  losing input when the live pad isn't at index 0. Xbox-style face
  button mapping: bottom face = Space (play/pause, and activates the
  focused control), right face = Escape (back), top face reveals the
  player screen's tool rail (focuses it into visibility via the
  existing CSS :focus-within rule). D-pad/stick repeat while held,
  mirroring OS keyboard auto-repeat.

- static/v3/gamepad-nav.js: fills the one real gap in that reuse
  strategy — no screen but the song library grid had any arrow-key
  navigation, and Chromium doesn't run native Enter/Space button
  activation for untrusted synthetic events even when dispatched at
  the focused element. Gated entirely on `!e.isTrusted`, so it only
  ever reacts to gamepad-originated events and never touches real
  keyboard/mouse users: emulates Tab-order (the sidebar + active
  screen's real, already-focusable buttons/links) for Arrow keys,
  explicitly .click()s the focused element for Enter/Space, and gives
  Escape a consistent "go back" behavior — an existing in-screen back
  button if one's visible (reusing each screen's own drill-down logic
  for free), else the main menu. Every branch defers via
  `e.defaultPrevented` to any screen that already handles the key
  itself (the song grid, the player, settings), so nothing here
  overrides existing behavior.

- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
  song library's virtualized grid (only a slice of the library is
  ever in the DOM), including fetching/scrolling off-screen rows into
  view and correcting for the sticky filter toolbar's occlusion.

- static/v3/index.html: wires up the two new scripts.

* chore: regenerate stale tailwind.min.css

Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.

* fix(gamepad): check all matching back buttons, not just the first

document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.

* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav

- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
  mapping === 'standard' like firstLiveStandardPad already does, and
  applied at the top of the gamepadconnected handler too. A still-
  connected non-standard raw mirror could otherwise mask the real
  pad's disconnect (toast never fires, polling never stops).

- songs.js _gpMove: an unset cursor now always seeds at index 0
  before the first press, instead of applying that press's delta
  immediately (ArrowDown/Right previously skipped straight past row
  0; Left/Up only looked right by accident of clamping). Matches the
  existing convention in shortcuts.js's legacy _handleLibArrowNav.

- songs.js _gpBlockedTarget: form-control/button blocking now
  requires the element to be visible (offsetParent !== null), not
  just present. Screens stay in the DOM hidden (not removed) when you
  navigate away, so a real button focused on some other now-hidden
  screen could leave document.activeElement pointing at it and block
  all grid navigation indefinitely. (An el.closest('#v3-songs') scope
  was tried first and reverted — it fixed that case but broke
  blocking for the topbar search input, which lives outside
  #v3-songs's DOM subtree even while v3-songs is active; visibility
  is the distinction that actually matters, not DOM nesting.)

Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.

Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.

* test(gamepad): unit-cover the controller + nav state machines

- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
  dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
  analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
  traversal + clamping, hidden-element skipping, Enter/Space click activation
  (not into text fields/body), Escape visible-back-button vs home fallback.

songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:52:51 +02:00

196 lines
8.4 KiB
JavaScript

// Gamepad/controller support.
//
// Rather than a parallel gamepad->action mapping table, this polls
// navigator.getGamepads() and dispatches synthetic keydown events onto
// document with the same key/code pairs a physical keyboard would send.
// static/js/shortcuts.js's existing dispatcher (scope checks, text-field/
// modal guards, library grid nav, player shortcuts) handles the rest.
//
// Steam Deck: Steam Input re-emits the Deck's controls as a standard
// XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when
// launched via a non-Steam shortcut with a controller template), so this
// reports mapping: 'standard' and the button layout below lines up with
// the Deck's physical ABXY. If a pad reports a non-standard mapping
// (e.g. raw HID with no Steam Input in between), this no-ops rather than
// guessing button order.
//
// Plain non-module script; degrades to a no-op without the Gamepad API.
(function () {
'use strict';
if (typeof navigator === 'undefined' || !navigator.getGamepads) return;
var BUTTON_KEYS = {
// Bottom face button (Xbox A / PS Cross "X") — play/pause on the player
// screen; also activates the currently-selected library card, since
// Space is already treated as an activation key there alongside Enter.
0: { key: ' ', code: 'Space' },
1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle
// 2 (Xbox X / PS Square) intentionally unmapped — undecided.
};
var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail
// The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's
// shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So
// instead of a synthetic keydown, this directly focuses the rail's first
// icon, which the existing :focus-within rule already reveals it for —
// the same mechanism a Tab-key user gets for free.
function revealPlayerRail() {
var active = document.querySelector('.screen.active');
if (!active || active.id !== 'player') return;
var icon = document.querySelector('#v3-player-rail .v3-rail-icon');
if (icon) icon.focus();
}
var DPAD_BUTTONS = {
12: { key: 'ArrowUp', code: 'ArrowUp' },
13: { key: 'ArrowDown', code: 'ArrowDown' },
14: { key: 'ArrowLeft', code: 'ArrowLeft' },
15: { key: 'ArrowRight', code: 'ArrowRight' },
};
var STICK_DEADZONE = 0.5;
var REPEAT_DELAY_MS = 400;
var REPEAT_INTERVAL_MS = 120;
var polling = false;
var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat)
var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool
var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat
var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced
function fireKey(spec) {
// Dispatch on the focused element (falling back to document when nothing
// is focused), not document itself. document.activeElement is always an
// ancestor-inclusive descendant of document, so this still bubbles up
// through every existing document-level listener exactly as before — but
// now a focused <button>/<a> also gets its native Enter/Space activation
// (which never fires for a document-targeted event, since that native
// behavior is wired to the genuinely-focused element receiving the key),
// and any element-scoped keydown handler sees it too.
(document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', {
key: spec.key, code: spec.code, bubbles: true, cancelable: true,
}));
}
function pollButtons(gp) {
for (var i = 0; i < gp.buttons.length; i++) {
var down = gp.buttons[i].pressed;
if (down && !buttonWasDown[i]) {
if (i === RAIL_REVEAL_BUTTON) revealPlayerRail();
else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]);
}
buttonWasDown[i] = down;
}
}
function stickDirections(gp) {
var x = gp.axes[0] || 0;
var y = gp.axes[1] || 0;
return {
left: x < -STICK_DEADZONE,
right: x > STICK_DEADZONE,
up: y < -STICK_DEADZONE,
down: y > STICK_DEADZONE,
};
}
function pollDirection(name, spec, down, now) {
var wasDown = !!dirWasDown[name];
if (down && !wasDown) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_DELAY_MS;
} else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_INTERVAL_MS;
}
dirWasDown[name] = down;
}
function pollDpad(gp, now) {
var stick = stickDirections(gp);
Object.keys(DPAD_BUTTONS).forEach(function (idx) {
var spec = DPAD_BUTTONS[idx];
var name = spec.key.replace('Arrow', '').toLowerCase();
var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name];
pollDirection(name, spec, down, now);
});
}
// A disconnected gamepad's slot stays in the array (gp.connected flips to
// false) rather than being removed — a plain truthiness check on the array
// entry treats a stale, frozen-state disconnected pad as "still there"
// forever, which both swallows the disconnect notice and (if the real
// reconnected pad lands at a different index) reads dead input forever.
function firstLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return p;
}
return null;
}
// Same standard-mapping filter as firstLiveStandardPad — otherwise a
// still-connected non-standard raw mirror (or the real pad simply
// reporting a different mapping) can mask the actual pad's disconnect:
// the toast never fires and polling never stops, even though the pad
// this module can act on is gone.
function anyLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return true;
}
return false;
}
function tick() {
var gp = firstLiveStandardPad();
if (gp) {
pollButtons(gp);
pollDpad(gp, performance.now());
}
if (polling) requestAnimationFrame(tick);
}
function notify(title, icon) {
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 });
}
}
window.addEventListener('gamepadconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
// Non-standard slots (raw HID mirrors, or anything this module can't
// safely act on) are never tracked/toasted/polled for — only ever
// treat a standard-mapped pad as "a controller connected". Keeping a
// non-standard slot out of connectedIndices also keeps it out of
// anyLiveStandardPad's count, so it can't mask a real disconnect.
if (!e.gamepad || e.gamepad.mapping !== 'standard') return;
if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.)
// On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput
// slots of its own (same physical button presses, extra indices) — only
// toast for the first slot seen so plugging in one controller doesn't
// spam three "connected" notices.
var isFirstSlot = Object.keys(connectedIndices).length === 0;
connectedIndices[idx] = true;
if (isFirstSlot) notify('Controller connected', '🎮');
buttonWasDown = {};
dirWasDown = {};
dirRepeatAt = {};
if (!polling) {
polling = true;
requestAnimationFrame(tick);
}
});
window.addEventListener('gamepaddisconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
delete connectedIndices[idx];
if (!anyLiveStandardPad()) {
polling = false;
notify('Controller disconnected', '🔌');
}
});
})();