mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 06:42:32 +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>
101 lines
5.0 KiB
JavaScript
101 lines
5.0 KiB
JavaScript
// 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 <button>/<a> elements, so real Tab/Shift+Tab and real
|
|
// Enter/Space already work perfectly. The gap is that nothing ever calls
|
|
// .focus() on anything, and gamepad.js only ever synthesizes Arrow keydowns —
|
|
// it never sends Tab (browsers don't focus-traverse on a synthetic Tab anyway).
|
|
// This fills that gap by moving focus through the same set of elements Tab
|
|
// already visits, one step per Arrow press, treating Down/Right as "next" and
|
|
// Up/Left as "previous".
|
|
//
|
|
// Gated on !e.isTrusted so this NEVER touches real keyboard/mouse users — it
|
|
// only ever reacts to gamepad.js's synthetic events. Also bails whenever a more
|
|
// specific handler already claimed the key (songs.js's grid nav, shortcuts.js's
|
|
// legacy library arrow-nav, or the shortcuts registry's player-scope seek
|
|
// shortcuts all call preventDefault() before this listener runs, since script
|
|
// tag order puts them earlier in the document than this file).
|
|
(function () {
|
|
'use strict';
|
|
|
|
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
|
|
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
var ARROWS = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
|
|
var TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
|
|
|
|
function visible(el) {
|
|
return el.offsetParent !== null;
|
|
}
|
|
|
|
function focusScopeRoot() {
|
|
var modal = document.querySelector('[role="dialog"][aria-modal="true"], .feedBack-modal');
|
|
if (modal && visible(modal)) return [modal];
|
|
var nav = document.getElementById('v3-nav');
|
|
var screen = document.querySelector('.screen.active');
|
|
return [nav, screen].filter(Boolean);
|
|
}
|
|
|
|
function focusables() {
|
|
var roots = focusScopeRoot();
|
|
var els = [];
|
|
roots.forEach(function (root) {
|
|
Array.prototype.push.apply(els, root.querySelectorAll(FOCUSABLE));
|
|
});
|
|
return els.filter(visible);
|
|
}
|
|
|
|
function isTextInput(el) {
|
|
if (!el) return false;
|
|
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
|
|
return el.tagName === 'INPUT' && TEXT_INPUT_TYPES.includes((el.type || 'text').toLowerCase());
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.isTrusted || e.defaultPrevented) return;
|
|
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
// Chromium doesn't run the native "Enter/Space activates the focused
|
|
// link/button" default action for untrusted synthetic keydowns, even
|
|
// when dispatched straight at the focused element (confirmed by
|
|
// testing) — so without this, a focused sidebar link or dashboard
|
|
// button just sits there forever. click() works for untrusted events.
|
|
var active = document.activeElement;
|
|
if (active && active !== document.body && !isTextInput(active)) active.click();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
// Only 'player' and 'settings' have a registered Escape shortcut
|
|
// (shortcuts.js); every other screen (v3-songs, v3-plugins,
|
|
// v3-playlists, ...) leaves B with nothing to do — confirmed on-device,
|
|
// players get stuck unable to leave the library or any other screen.
|
|
// The app never pushes history entries on navigation (shell.js
|
|
// deliberately doesn't reflect screen changes into location.hash), so
|
|
// history.back() isn't a real "undo the last screen" — a fixed target
|
|
// is. Prefer an existing in-screen back button if one is visible
|
|
// (reuses each screen's own drill-down logic for free: v3-songs'
|
|
// artist/album pages, v3-playlists' list<->detail view), else fall
|
|
// back to the main menu, matching the direct showScreen() call the
|
|
// settings Escape shortcut already uses.
|
|
// querySelector alone would only ever look at the first match in
|
|
// DOM order across all three selectors — screens stay in the DOM
|
|
// (hidden, not removed) when you navigate away, so a hidden back
|
|
// button from a screen you're not on can sort before the visible
|
|
// one that actually applies. Check every match for visibility.
|
|
var backBtns = document.querySelectorAll('[data-ap-back], [data-albums-back], #v3-pl-back');
|
|
var backBtn = Array.prototype.find.call(backBtns, visible);
|
|
if (backBtn) backBtn.click();
|
|
else if (window.showScreen) window.showScreen('v3-home');
|
|
return;
|
|
}
|
|
|
|
var dir = ARROWS[e.key];
|
|
if (!dir) return;
|
|
var els = focusables();
|
|
if (!els.length) return;
|
|
var idx = els.indexOf(document.activeElement);
|
|
var next = idx === -1 ? 0 : Math.max(0, Math.min(els.length - 1, idx + dir));
|
|
els[next].focus();
|
|
});
|
|
})();
|