Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions static/v3/gamepad-nav.js
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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();
});
})();
195 changes: 195 additions & 0 deletions static/v3/gamepad.js
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', '🔌');
}
});
})();
2 changes: 2 additions & 0 deletions static/v3/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,7 @@ <h3>Gameplay Settings</h3>
<script defer src="/static/v3/theme-core.js"></script>
<script defer src="/static/v3/progression-core.js"></script>
<script defer src="/static/v3/notifications.js"></script>
<script defer src="/static/v3/gamepad.js"></script>
<script defer src="/static/v3/profile.js"></script>
<script defer src="/static/v3/progress.js"></script>
<script defer src="/static/v3/shop.js"></script>
Expand Down Expand Up @@ -1322,6 +1323,7 @@ <h3>Gameplay Settings</h3>
the cover picker (window.__fbOpenImagePicker). -->
<script defer src="/static/v3/image-picker.js"></script>
<script defer src="/static/v3/songs.js"></script>
<script defer src="/static/v3/gamepad-nav.js"></script>
<script defer src="/static/v3/lessons.js"></script>
<script defer src="/static/v3/dashboard.js"></script>
<script defer src="/static/v3/settings.js"></script>
Expand Down
Loading
Loading