-
Notifications
You must be signed in to change notification settings - Fork 38
Add gamepad/controller support #1001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
byrongamatos
merged 6 commits into
got-feedBack:main
from
mhglover:feat/gamepad-support
Jul 19, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
05afe7f
feat(input): add gamepad/controller support
mhglover a58d475
chore: regenerate stale tailwind.min.css
mhglover 346c0e1
fix(gamepad): check all matching back buttons, not just the first
mhglover 1069182
fix(gamepad): address CodeRabbit findings on connect/disconnect and g…
mhglover f65e3bc
Merge main into feat/gamepad-support; resolve conflicts + review fixes
byrongamatos 5a16447
test(gamepad): unit-cover the controller + nav state machines
byrongamatos File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <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(); | ||
| }); | ||
| })(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| // Gamepad/controller support. | ||
| // | ||
| // Rather than a parallel gamepad->action mapping table, this polls | ||
| // navigator.getGamepads() and dispatches synthetic keydown events onto | ||
| // document with the same key/code pairs a physical keyboard would send. | ||
| // static/js/shortcuts.js's existing dispatcher (scope checks, text-field/ | ||
| // modal guards, library grid nav, player shortcuts) handles the rest. | ||
| // | ||
| // Steam Deck: Steam Input re-emits the Deck's controls as a standard | ||
| // XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when | ||
| // launched via a non-Steam shortcut with a controller template), so this | ||
| // reports mapping: 'standard' and the button layout below lines up with | ||
| // the Deck's physical ABXY. If a pad reports a non-standard mapping | ||
| // (e.g. raw HID with no Steam Input in between), this no-ops rather than | ||
| // guessing button order. | ||
| // | ||
| // Plain non-module script; degrades to a no-op without the Gamepad API. | ||
| (function () { | ||
| 'use strict'; | ||
|
|
||
| if (typeof navigator === 'undefined' || !navigator.getGamepads) return; | ||
|
|
||
| var BUTTON_KEYS = { | ||
| // Bottom face button (Xbox A / PS Cross "X") — play/pause on the player | ||
| // screen; also activates the currently-selected library card, since | ||
| // Space is already treated as an activation key there alongside Enter. | ||
| 0: { key: ' ', code: 'Space' }, | ||
| 1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle | ||
| // 2 (Xbox X / PS Square) intentionally unmapped — undecided. | ||
| }; | ||
| var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail | ||
|
|
||
| // The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's | ||
| // shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So | ||
| // instead of a synthetic keydown, this directly focuses the rail's first | ||
| // icon, which the existing :focus-within rule already reveals it for — | ||
| // the same mechanism a Tab-key user gets for free. | ||
| function revealPlayerRail() { | ||
| var active = document.querySelector('.screen.active'); | ||
| if (!active || active.id !== 'player') return; | ||
| var icon = document.querySelector('#v3-player-rail .v3-rail-icon'); | ||
| if (icon) icon.focus(); | ||
| } | ||
| var DPAD_BUTTONS = { | ||
| 12: { key: 'ArrowUp', code: 'ArrowUp' }, | ||
| 13: { key: 'ArrowDown', code: 'ArrowDown' }, | ||
| 14: { key: 'ArrowLeft', code: 'ArrowLeft' }, | ||
| 15: { key: 'ArrowRight', code: 'ArrowRight' }, | ||
| }; | ||
| var STICK_DEADZONE = 0.5; | ||
| var REPEAT_DELAY_MS = 400; | ||
| var REPEAT_INTERVAL_MS = 120; | ||
|
|
||
| var polling = false; | ||
| var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat) | ||
| var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool | ||
| var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat | ||
| var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced | ||
|
|
||
| function fireKey(spec) { | ||
| // Dispatch on the focused element (falling back to document when nothing | ||
| // is focused), not document itself. document.activeElement is always an | ||
| // ancestor-inclusive descendant of document, so this still bubbles up | ||
| // through every existing document-level listener exactly as before — but | ||
| // now a focused <button>/<a> also gets its native Enter/Space activation | ||
| // (which never fires for a document-targeted event, since that native | ||
| // behavior is wired to the genuinely-focused element receiving the key), | ||
| // and any element-scoped keydown handler sees it too. | ||
| (document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', { | ||
| key: spec.key, code: spec.code, bubbles: true, cancelable: true, | ||
| })); | ||
| } | ||
|
|
||
| function pollButtons(gp) { | ||
| for (var i = 0; i < gp.buttons.length; i++) { | ||
| var down = gp.buttons[i].pressed; | ||
| if (down && !buttonWasDown[i]) { | ||
| if (i === RAIL_REVEAL_BUTTON) revealPlayerRail(); | ||
| else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]); | ||
| } | ||
| buttonWasDown[i] = down; | ||
| } | ||
| } | ||
|
|
||
| function stickDirections(gp) { | ||
| var x = gp.axes[0] || 0; | ||
| var y = gp.axes[1] || 0; | ||
| return { | ||
| left: x < -STICK_DEADZONE, | ||
| right: x > STICK_DEADZONE, | ||
| up: y < -STICK_DEADZONE, | ||
| down: y > STICK_DEADZONE, | ||
| }; | ||
| } | ||
|
|
||
| function pollDirection(name, spec, down, now) { | ||
| var wasDown = !!dirWasDown[name]; | ||
| if (down && !wasDown) { | ||
| fireKey(spec); | ||
| dirRepeatAt[name] = now + REPEAT_DELAY_MS; | ||
| } else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) { | ||
| fireKey(spec); | ||
| dirRepeatAt[name] = now + REPEAT_INTERVAL_MS; | ||
| } | ||
| dirWasDown[name] = down; | ||
| } | ||
|
|
||
| function pollDpad(gp, now) { | ||
| var stick = stickDirections(gp); | ||
| Object.keys(DPAD_BUTTONS).forEach(function (idx) { | ||
| var spec = DPAD_BUTTONS[idx]; | ||
| var name = spec.key.replace('Arrow', '').toLowerCase(); | ||
| var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name]; | ||
| pollDirection(name, spec, down, now); | ||
| }); | ||
| } | ||
|
|
||
| // A disconnected gamepad's slot stays in the array (gp.connected flips to | ||
| // false) rather than being removed — a plain truthiness check on the array | ||
| // entry treats a stale, frozen-state disconnected pad as "still there" | ||
| // forever, which both swallows the disconnect notice and (if the real | ||
| // reconnected pad lands at a different index) reads dead input forever. | ||
| function firstLiveStandardPad() { | ||
| var pads = navigator.getGamepads ? navigator.getGamepads() : []; | ||
| for (var i = 0; i < pads.length; i++) { | ||
| var p = pads[i]; | ||
| if (p && p.connected && p.mapping === 'standard') return p; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // Same standard-mapping filter as firstLiveStandardPad — otherwise a | ||
| // still-connected non-standard raw mirror (or the real pad simply | ||
| // reporting a different mapping) can mask the actual pad's disconnect: | ||
| // the toast never fires and polling never stops, even though the pad | ||
| // this module can act on is gone. | ||
| function anyLiveStandardPad() { | ||
| var pads = navigator.getGamepads ? navigator.getGamepads() : []; | ||
| for (var i = 0; i < pads.length; i++) { | ||
| var p = pads[i]; | ||
| if (p && p.connected && p.mapping === 'standard') return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function tick() { | ||
| var gp = firstLiveStandardPad(); | ||
| if (gp) { | ||
| pollButtons(gp); | ||
| pollDpad(gp, performance.now()); | ||
| } | ||
| if (polling) requestAnimationFrame(tick); | ||
| } | ||
|
|
||
| function notify(title, icon) { | ||
| if (window.fbNotify && typeof window.fbNotify.show === 'function') { | ||
| window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 }); | ||
| } | ||
| } | ||
|
|
||
| window.addEventListener('gamepadconnected', function (e) { | ||
| var idx = e.gamepad && e.gamepad.index; | ||
| // Non-standard slots (raw HID mirrors, or anything this module can't | ||
| // safely act on) are never tracked/toasted/polled for — only ever | ||
| // treat a standard-mapped pad as "a controller connected". Keeping a | ||
| // non-standard slot out of connectedIndices also keeps it out of | ||
| // anyLiveStandardPad's count, so it can't mask a real disconnect. | ||
| if (!e.gamepad || e.gamepad.mapping !== 'standard') return; | ||
| if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.) | ||
| // On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput | ||
| // slots of its own (same physical button presses, extra indices) — only | ||
| // toast for the first slot seen so plugging in one controller doesn't | ||
| // spam three "connected" notices. | ||
| var isFirstSlot = Object.keys(connectedIndices).length === 0; | ||
| connectedIndices[idx] = true; | ||
|
|
||
| if (isFirstSlot) notify('Controller connected', '🎮'); | ||
| buttonWasDown = {}; | ||
| dirWasDown = {}; | ||
| dirRepeatAt = {}; | ||
| if (!polling) { | ||
| polling = true; | ||
| requestAnimationFrame(tick); | ||
| } | ||
| }); | ||
|
|
||
| window.addEventListener('gamepaddisconnected', function (e) { | ||
| var idx = e.gamepad && e.gamepad.index; | ||
| delete connectedIndices[idx]; | ||
| if (!anyLiveStandardPad()) { | ||
| polling = false; | ||
| notify('Controller disconnected', '🔌'); | ||
| } | ||
| }); | ||
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.