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>
158 lines
5.9 KiB
JavaScript
158 lines
5.9 KiB
JavaScript
// 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, []);
|
|
});
|