mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +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
@@ -4259,6 +4259,127 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ── Grid cursor navigation (arrow keys / gamepad d-pad) ─────────────────
|
||||
// shortcuts.js's generic library arrow-nav (_handleLibArrowNav) can't reach
|
||||
// this screen: it assumes every navigable item is already a DOM node, but
|
||||
// this grid is windowed — most of the library isn't in the DOM at any given
|
||||
// scroll position. Cursor state here is an absolute index into state.songs,
|
||||
// and moving it may need to fetch a page and/or scroll the window before the
|
||||
// target card exists to highlight or activate.
|
||||
let _gpIdx = null;
|
||||
|
||||
function _gpCardEl(idx) {
|
||||
return document.querySelector('#v3-songs-grid [data-idx="' + idx + '"]');
|
||||
}
|
||||
|
||||
function _gpApplyHighlight() {
|
||||
const prev = document.querySelector('#v3-songs-grid [data-gp-cursor]');
|
||||
if (prev) {
|
||||
prev.removeAttribute('data-gp-cursor');
|
||||
prev.querySelector('[data-v3-play]')?.classList.remove('ring-2', 'ring-fb-primary');
|
||||
}
|
||||
if (_gpIdx == null) return;
|
||||
const el = _gpCardEl(_gpIdx);
|
||||
if (!el) return;
|
||||
el.setAttribute('data-gp-cursor', '1');
|
||||
el.querySelector('[data-v3-play]')?.classList.add('ring-2', 'ring-fb-primary');
|
||||
}
|
||||
|
||||
async function _gpEnsureVisible(idx) {
|
||||
const main = document.getElementById('v3-main'), sizer = _sizerEl();
|
||||
if (!main || !sizer) return;
|
||||
const { cols, rowH } = measureGeom();
|
||||
const row = Math.floor(idx / Math.max(1, cols));
|
||||
const sizerTop = _sizerTopInScroller(main, sizer);
|
||||
const rowTop = sizerTop + row * rowH;
|
||||
const rowBottom = rowTop + rowH;
|
||||
const viewTop = main.scrollTop, viewBottom = viewTop + main.clientHeight;
|
||||
if (rowTop < viewTop) main.scrollTop = rowTop;
|
||||
else if (rowBottom > viewBottom) main.scrollTop = rowBottom - main.clientHeight;
|
||||
await renderWindow();
|
||||
|
||||
// #v3-songs-toolbar is sticky, but only pins to the top once scrolled
|
||||
// PAST its natural in-flow position — before that point it isn't
|
||||
// covering anything, after it covers a fixed band. That makes its
|
||||
// occlusion scroll-dependent in a way no single precomputed offset
|
||||
// captures, so measure the real rendered overlap and correct for it,
|
||||
// rather than assuming the toolbar's height is always "lost" space.
|
||||
const toolbar = document.getElementById('v3-songs-toolbar');
|
||||
const cardEl = _gpCardEl(idx);
|
||||
if (toolbar && cardEl) {
|
||||
const overlap = toolbar.getBoundingClientRect().bottom - cardEl.getBoundingClientRect().top;
|
||||
if (overlap > 0) {
|
||||
// Push the row DOWN the screen to clear the toolbar — that means
|
||||
// scrolling the content back UP, i.e. decreasing scrollTop (screen
|
||||
// position = content position - scrollTop).
|
||||
main.scrollTop -= overlap;
|
||||
await renderWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _gpMove(delta) {
|
||||
if (!state.total) return;
|
||||
if (_gpIdx == null) {
|
||||
// Nothing selected yet — land on the first card and stop, same as
|
||||
// shortcuts.js's legacy _handleLibArrowNav does for an empty
|
||||
// selection: the first press establishes a cursor, it doesn't also
|
||||
// move it (Right/Down previously skipped straight past row 0).
|
||||
_gpIdx = 0;
|
||||
} else {
|
||||
const next = Math.max(0, Math.min(state.total - 1, _gpIdx + delta));
|
||||
if (next === _gpIdx) return;
|
||||
_gpIdx = next;
|
||||
}
|
||||
await ensureWindow(Math.max(0, _gpIdx - 1), Math.min(state.total, _gpIdx + 2));
|
||||
await _gpEnsureVisible(_gpIdx);
|
||||
_gpApplyHighlight();
|
||||
}
|
||||
|
||||
function _gpActivate() {
|
||||
if (_gpIdx == null) return;
|
||||
_gpCardEl(_gpIdx)?.querySelector('[data-v3-play]')?.click();
|
||||
}
|
||||
|
||||
// Bails on focus inside anything with its own keyboard semantics — form
|
||||
// controls, buttons, and dialog/drawer overlays — same intent as
|
||||
// shortcuts.js's _isInsideInteractiveControl, reimplemented locally since
|
||||
// this is a plain script (not an ES module) and can't import it.
|
||||
//
|
||||
// The form-control/button check requires the element to be VISIBLE, not
|
||||
// merely present: screens stay in the DOM (hidden, not removed) when you
|
||||
// navigate away, so a real <button> focused on some OTHER now-hidden
|
||||
// screen (dashboard, etc.) can leave document.activeElement pointing at
|
||||
// it — an el.closest('#v3-songs') scope check would let that stale focus
|
||||
// through fine, but would ALSO wrongly stop blocking genuinely-focused
|
||||
// shared chrome like the topbar search input (#v3-search lives outside
|
||||
// #v3-songs's DOM subtree even while v3-songs is the active screen).
|
||||
// Visibility is the actual distinction that matters here, not DOM
|
||||
// nesting. The dialog/drawer-overlay and contentEditable checks stay as
|
||||
// they were — overlay-level concerns regardless of which screen sits
|
||||
// underneath.
|
||||
function _gpBlockedTarget(el) {
|
||||
if (!el) return false;
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(el.tagName) && el.offsetParent !== null) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.closest && el.closest('[role="dialog"], .feedBack-modal, #lib-filter-drawer')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!songsActive() || state.view !== 'grid') return;
|
||||
if (_gpBlockedTarget(document.activeElement)) return;
|
||||
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
|
||||
if (!isActivate && !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
if (isActivate) { _gpActivate(); return; }
|
||||
const { cols } = measureGeom();
|
||||
if (e.key === 'ArrowRight') _gpMove(1);
|
||||
else if (e.key === 'ArrowLeft') _gpMove(-1);
|
||||
else if (e.key === 'ArrowDown') _gpMove(cols);
|
||||
else if (e.key === 'ArrowUp') _gpMove(-cols);
|
||||
});
|
||||
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
sm.on('screen:changed', (e) => {
|
||||
const id = e && e.detail && e.detail.id;
|
||||
|
||||
Reference in New Issue
Block a user