mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
* 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>
183 lines
6.6 KiB
JavaScript
183 lines
6.6 KiB
JavaScript
// 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');
|
|
});
|