From 23c509322b79954d656fb164ffaed21ac6ff8ce2 Mon Sep 17 00:00:00 2001 From: Matthew Harris Glover Date: Sun, 19 Jul 2026 05:52:51 -0400 Subject: [PATCH] Add gamepad/controller support (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) Signed-off-by: Byron Gamatos --------- Signed-off-by: Byron Gamatos Co-authored-by: Byron Gamatos Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/gamepad-nav.js | 100 ++++++++++++++++++ static/v3/gamepad.js | 195 +++++++++++++++++++++++++++++++++++ static/v3/index.html | 2 + static/v3/songs.js | 121 ++++++++++++++++++++++ tests/js/gamepad.test.js | 182 ++++++++++++++++++++++++++++++++ tests/js/gamepad_nav.test.js | 157 ++++++++++++++++++++++++++++ 6 files changed, 757 insertions(+) create mode 100644 static/v3/gamepad-nav.js create mode 100644 static/v3/gamepad.js create mode 100644 tests/js/gamepad.test.js create mode 100644 tests/js/gamepad_nav.test.js diff --git a/static/v3/gamepad-nav.js b/static/v3/gamepad-nav.js new file mode 100644 index 0000000..df1aca6 --- /dev/null +++ b/static/v3/gamepad-nav.js @@ -0,0 +1,100 @@ +// Generic gamepad menu navigation: Tab-order emulation. +// +// Every v3 screen except v3-songs (which has its own 2D grid nav) is built from +// real, natively-focusable