mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
Byron Gamatos
parent
fcdb4867d6
commit
23c509322b
@@ -0,0 +1,182 @@
|
||||
// Behavioral tests for static/v3/gamepad.js — the controller polling state
|
||||
// machine. gamepad.js is a plain IIFE with no exports, so it's loaded into a vm
|
||||
// with a fake navigator/window/document and driven frame-by-frame through a
|
||||
// manual requestAnimationFrame queue. This exercises the parts that were only
|
||||
// ever checked on a real Steam Deck: standard-mapping filtering, Steam Input's
|
||||
// duplicate-slot dedup, disconnect masking, button edge-detection, d-pad/stick
|
||||
// key-repeat timing, and the analog-stick deadzone.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad.js'), 'utf8');
|
||||
|
||||
function pad(index, opts = {}) {
|
||||
return {
|
||||
index,
|
||||
connected: opts.connected !== false,
|
||||
mapping: opts.mapping || 'standard',
|
||||
buttons: (opts.buttons || []).map(p => ({ pressed: !!p })),
|
||||
axes: opts.axes || [0, 0],
|
||||
};
|
||||
}
|
||||
|
||||
// Load a fresh gamepad.js instance with a controllable environment.
|
||||
function load() {
|
||||
let pads = [];
|
||||
const listeners = {};
|
||||
const rafQueue = [];
|
||||
const fired = []; // synthetic key codes dispatched at the focused element
|
||||
const toasts = []; // {title,...} from fbNotify.show
|
||||
let clock = 0;
|
||||
|
||||
const activeElement = { dispatchEvent(evt) { fired.push(evt.code); return true; } };
|
||||
const sandbox = {
|
||||
console: { log() {}, error() {} },
|
||||
performance: { now: () => clock },
|
||||
requestAnimationFrame: (fn) => { rafQueue.push(fn); return rafQueue.length; },
|
||||
navigator: { getGamepads: () => pads },
|
||||
KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
|
||||
document: {
|
||||
activeElement,
|
||||
// revealPlayerRail() looks these up; returning null makes button 3 a no-op.
|
||||
querySelector: () => null,
|
||||
},
|
||||
window: {
|
||||
addEventListener: (t, fn) => { (listeners[t] || (listeners[t] = [])).push(fn); },
|
||||
fbNotify: { show: (o) => toasts.push(o) },
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(SRC, sandbox);
|
||||
|
||||
const emit = (type, gamepad) => (listeners[type] || []).forEach(fn => fn({ gamepad }));
|
||||
return {
|
||||
setPads: (arr) => { pads = arr; },
|
||||
connect: (gp) => emit('gamepadconnected', gp),
|
||||
disconnect: (gp) => emit('gamepaddisconnected', gp),
|
||||
tick: () => { const fn = rafQueue.shift(); if (fn) fn(); },
|
||||
polling: () => rafQueue.length > 0, // a live tick re-queues itself only while polling
|
||||
setClock: (t) => { clock = t; },
|
||||
fired, toasts,
|
||||
};
|
||||
}
|
||||
|
||||
test('a non-standard pad is ignored entirely (no toast, no polling)', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { mapping: 'xbox-nonstandard' });
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
assert.equal(g.toasts.length, 0);
|
||||
assert.equal(g.polling(), false);
|
||||
});
|
||||
|
||||
test('a standard pad connecting toasts once and starts polling', () => {
|
||||
const g = load();
|
||||
const p = pad(0);
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
assert.equal(g.toasts.length, 1);
|
||||
assert.equal(g.toasts[0].title, 'Controller connected');
|
||||
assert.equal(g.polling(), true);
|
||||
});
|
||||
|
||||
test("Steam Input's duplicate virtual slots only toast once", () => {
|
||||
const g = load();
|
||||
const a = pad(0), b = pad(1);
|
||||
g.setPads([a, b]);
|
||||
g.connect(a);
|
||||
g.connect(b); // same physical controller, second XInput mirror slot
|
||||
assert.equal(g.toasts.length, 1, 'one physical controller = one toast');
|
||||
});
|
||||
|
||||
test('face buttons edge-detect: fire once per press, not once per frame', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [true] }); // button 0 held down
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
g.tick();
|
||||
g.tick(); // still held on the next frame
|
||||
assert.deepEqual(g.fired, ['Space'], 'held button must not auto-repeat');
|
||||
|
||||
p.buttons[0].pressed = false; g.tick(); // release
|
||||
p.buttons[0].pressed = true; g.tick(); // press again
|
||||
assert.deepEqual(g.fired, ['Space', 'Space'], 'a fresh press fires again');
|
||||
});
|
||||
|
||||
test('button 1 maps to Escape; button 3 (rail reveal) fires no key', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [false, true, false, true] });
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
g.tick();
|
||||
assert.deepEqual(g.fired, ['Escape'], 'B=Escape, Y=rail-reveal (no synthetic key)');
|
||||
});
|
||||
|
||||
test('d-pad / stick repeat: initial fire, delay, then interval repeats', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [] }); // no buttons; drive via the d-pad indices
|
||||
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||
p.buttons[13].pressed = true; // ArrowDown
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
|
||||
g.setClock(0); g.tick(); // initial press
|
||||
g.setClock(399); g.tick(); // before the 400ms repeat delay
|
||||
g.setClock(400); g.tick(); // repeat delay elapsed
|
||||
assert.deepEqual(g.fired, ['ArrowDown', 'ArrowDown'], 'one initial + one repeat at 400ms, nothing at 399ms');
|
||||
});
|
||||
|
||||
test('analog stick honors the deadzone', () => {
|
||||
const g = load();
|
||||
const p = pad(0);
|
||||
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
|
||||
p.axes = [0, 0.4]; g.setClock(0); g.tick(); // below 0.5 deadzone → nothing
|
||||
assert.deepEqual(g.fired, [], 'sub-deadzone deflection is ignored');
|
||||
p.axes = [0.6, 0]; g.setClock(1); g.tick(); // right, past deadzone
|
||||
assert.deepEqual(g.fired, ['ArrowRight']);
|
||||
});
|
||||
|
||||
test('disconnecting one of two live slots does not stop polling or toast', () => {
|
||||
const g = load();
|
||||
const a = pad(0), b = pad(1);
|
||||
g.setPads([a, b]);
|
||||
g.connect(a); g.connect(b);
|
||||
g.toasts.length = 0;
|
||||
|
||||
b.connected = false; // Steam mirror slot drops
|
||||
g.setPads([a, b]);
|
||||
g.disconnect(b);
|
||||
assert.equal(g.toasts.length, 0, 'a still-live standard pad masks the mirror disconnect');
|
||||
assert.equal(g.polling(), true);
|
||||
});
|
||||
|
||||
test('disconnecting the last live pad stops polling and toasts', () => {
|
||||
const g = load();
|
||||
const a = pad(0);
|
||||
g.setPads([a]);
|
||||
g.connect(a);
|
||||
a.connected = false;
|
||||
g.setPads([a]);
|
||||
g.disconnect(a);
|
||||
assert.equal(g.toasts.some(t => t.title === 'Controller disconnected'), true);
|
||||
// Drain the final queued tick; polling must not re-queue itself.
|
||||
g.tick();
|
||||
assert.equal(g.polling(), false);
|
||||
});
|
||||
|
||||
test('polling acts only on the live standard pad, skipping stale/non-standard slots', () => {
|
||||
const g = load();
|
||||
const dead = pad(0, { connected: false, buttons: [true] }); // frozen, disconnected
|
||||
const raw = pad(1, { mapping: 'raw-hid', buttons: [true] }); // non-standard
|
||||
const live = pad(2, { buttons: [true] }); // standard, button 0 down
|
||||
g.setPads([dead, raw, live]);
|
||||
g.connect(live);
|
||||
g.tick();
|
||||
assert.deepEqual(g.fired, ['Space'], 'input read from the live standard pad only');
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// Behavioral tests for static/v3/gamepad-nav.js — the generic Tab-order
|
||||
// emulation layer. Loaded into a vm with a minimal fake DOM; the module's
|
||||
// single keydown listener is captured and fed synthetic events. Covers the
|
||||
// three things it does: arrow-key focus traversal (with clamping), Enter/Space
|
||||
// activation via .click() (Chromium won't natively activate untrusted keys),
|
||||
// and the Escape "go back" fallback — plus the !isTrusted / defaultPrevented
|
||||
// gating that keeps it off real keyboard users.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad-nav.js'), 'utf8');
|
||||
|
||||
function load() {
|
||||
const state = { focused: null, clicked: [], screens: [] };
|
||||
const body = { tagName: 'BODY' };
|
||||
const cfg = { modal: null, nav: null, screen: null, backButtons: [], activeEl: body };
|
||||
let handler = null;
|
||||
|
||||
function elem(opts = {}) {
|
||||
return {
|
||||
tagName: opts.tagName || 'BUTTON',
|
||||
type: opts.type,
|
||||
isContentEditable: !!opts.isContentEditable,
|
||||
offsetParent: opts.visible === false ? null : {},
|
||||
_focusables: opts.focusables || [],
|
||||
querySelectorAll() { return this._focusables; },
|
||||
focus() { state.focused = this; },
|
||||
click() { state.clicked.push(this); },
|
||||
};
|
||||
}
|
||||
|
||||
const document = {
|
||||
body,
|
||||
get activeElement() { return cfg.activeEl; },
|
||||
addEventListener(type, fn) { if (type === 'keydown') handler = fn; },
|
||||
querySelector(sel) {
|
||||
if (sel.includes('dialog') || sel.includes('modal')) return cfg.modal;
|
||||
if (sel.includes('screen.active')) return cfg.screen;
|
||||
return null;
|
||||
},
|
||||
getElementById(id) { return id === 'v3-nav' ? cfg.nav : null; },
|
||||
querySelectorAll() { return cfg.backButtons; }, // only the Escape back-button lookup uses this
|
||||
};
|
||||
const sandbox = { document, window: { showScreen: (id) => state.screens.push(id) } };
|
||||
vm.runInNewContext(SRC, sandbox);
|
||||
|
||||
const fire = (over) => handler(Object.assign({ isTrusted: false, defaultPrevented: false, key: '' }, over));
|
||||
return { cfg, state, body, elem, fire };
|
||||
}
|
||||
|
||||
// Build a screen holding `n` visible focusables; expose them for cfg.activeEl.
|
||||
function screenWith(g, n) {
|
||||
const items = Array.from({ length: n }, () => g.elem());
|
||||
g.cfg.screen = g.elem({ focusables: items });
|
||||
g.cfg.nav = g.elem({ focusables: [] });
|
||||
return items;
|
||||
}
|
||||
|
||||
test('real keyboard input (isTrusted) is never touched', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ isTrusted: true, key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, null, 'trusted events must pass through untouched');
|
||||
});
|
||||
|
||||
test('a key already handled by another listener (defaultPrevented) is skipped', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ defaultPrevented: true, key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, null);
|
||||
});
|
||||
|
||||
test('ArrowDown/Right moves to the next focusable; ArrowUp/Left to the previous', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[1];
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, items[2], 'Down = next');
|
||||
|
||||
g.cfg.activeEl = items[1];
|
||||
g.fire({ key: 'ArrowLeft' });
|
||||
assert.equal(g.state.focused, items[0], 'Left = previous');
|
||||
});
|
||||
|
||||
test('traversal clamps at both ends', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[2];
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, items[2], 'no wrap past the last item');
|
||||
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ key: 'ArrowUp' });
|
||||
assert.equal(g.state.focused, items[0], 'no wrap before the first item');
|
||||
});
|
||||
|
||||
test('with nothing relevant focused, the first arrow lands on the first item', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = g.body; // not in the focusable list
|
||||
g.fire({ key: 'ArrowRight' });
|
||||
assert.equal(g.state.focused, items[0]);
|
||||
});
|
||||
|
||||
test('hidden focusables are skipped (offsetParent visibility)', () => {
|
||||
const g = load();
|
||||
const visibleA = g.elem();
|
||||
const hidden = g.elem({ visible: false });
|
||||
const visibleB = g.elem();
|
||||
g.cfg.screen = g.elem({ focusables: [visibleA, hidden, visibleB] });
|
||||
g.cfg.nav = g.elem({ focusables: [] });
|
||||
g.cfg.activeEl = visibleA;
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, visibleB, 'the hidden element is not a traversal stop');
|
||||
});
|
||||
|
||||
test('Enter/Space activates the focused control via click()', () => {
|
||||
const g = load();
|
||||
const btn = g.elem({ tagName: 'BUTTON' });
|
||||
g.cfg.activeEl = btn;
|
||||
g.fire({ key: 'Enter' });
|
||||
g.fire({ key: ' ' });
|
||||
assert.deepEqual(g.state.clicked, [btn, btn], 'both Enter and Space activate');
|
||||
});
|
||||
|
||||
test('activation never clicks a focused text field or the body', () => {
|
||||
const g = load();
|
||||
g.cfg.activeEl = g.elem({ tagName: 'INPUT', type: 'text' });
|
||||
g.fire({ key: 'Enter' });
|
||||
g.cfg.activeEl = g.body;
|
||||
g.fire({ key: ' ' });
|
||||
assert.deepEqual(g.state.clicked, [], 'no synthetic click into a text input or the bare body');
|
||||
});
|
||||
|
||||
test('Escape clicks the visible in-screen back button when one exists', () => {
|
||||
const g = load();
|
||||
const hiddenBack = g.elem({ visible: false }); // a back button from another, now-hidden screen
|
||||
const visibleBack = g.elem();
|
||||
g.cfg.backButtons = [hiddenBack, visibleBack];
|
||||
g.fire({ key: 'Escape' });
|
||||
assert.deepEqual(g.state.clicked, [visibleBack], 'the visible back button wins, not DOM order');
|
||||
assert.deepEqual(g.state.screens, [], 'no home fallback while a back button handled it');
|
||||
});
|
||||
|
||||
test('Escape with no visible back button falls back to the home screen', () => {
|
||||
const g = load();
|
||||
g.cfg.backButtons = [g.elem({ visible: false })];
|
||||
g.fire({ key: 'Escape' });
|
||||
assert.deepEqual(g.state.screens, ['v3-home']);
|
||||
assert.deepEqual(g.state.clicked, []);
|
||||
});
|
||||
Reference in New Issue
Block a user