fix(controller): pick up macOS presses while mpv is in front (#321)

This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 21:37:11 -08:00
1 parent fa3051f9cb
commit 8e29cb3c55
17 files changed
+614 -63

No files matched your search

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerPressReplay } from './controller-press-replay';
import type { GamepadLike } from './gamepad-controller';
function pad(pressed: number[] = [], mapping = 'standard'): GamepadLike {
return {
id: 'pad',
index: 0,
connected: true,
mapping,
axes: [0, 0, 0, 0],
buttons: Array.from({ length: 17 }, (_, index) => ({
value: pressed.includes(index) ? 1 : 0,
pressed: pressed.includes(index),
})),
};
}
const pressedIndices = (gamepad: GamepadLike | null | undefined): number[] =>
gamepad?.buttons.flatMap((button, index) => (button.pressed ? [index] : [])) ?? [];
test('a queued press reads as pressed for exactly one poll', () => {
const replay = createControllerPressReplay();
replay.queue(0, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
});
test('a press the Gamepad API already reported is not replayed', () => {
const replay = createControllerPressReplay();
replay.apply([pad([1])], 990);
replay.queue(1, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), []);
});
test('a press seen after the replay was queued is not replayed again', () => {
const replay = createControllerPressReplay();
replay.queue(2, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad([2])], 1010)[0]), [2]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
});
test('an older press of the same button does not block the replay', () => {
const replay = createControllerPressReplay();
replay.apply([pad([3])], 0);
replay.queue(3, 5000);
assert.deepEqual(pressedIndices(replay.apply([pad()], 5010)[0]), [3]);
});
test('a replayed native pad keeps its fields', () => {
// Like the browser's Gamepad, these fields are prototype getters that object spread skips.
class NativePad implements GamepadLike {
get id() {
return 'pad';
}
get index() {
return 2;
}
get connected() {
return true;
}
get mapping() {
return 'standard';
}
get timestamp() {
return 900;
}
get axes() {
return [0.5, 0];
}
get buttons() {
return pad().buttons;
}
}
const replay = createControllerPressReplay();
replay.queue(0, 1000);
const replayed = replay.apply([new NativePad()], 1010)[0];
assert.deepEqual(pressedIndices(replayed), [0]);
assert.equal(replayed?.id, 'pad');
assert.equal(replayed?.index, 2);
assert.equal(replayed?.connected, true);
assert.equal(replayed?.mapping, 'standard');
assert.equal(replayed?.timestamp, 900);
assert.deepEqual(replayed?.axes, [0.5, 0]);
});
test('nothing is replayed while several pads are connected', () => {
const replay = createControllerPressReplay();
replay.queue(0, 1000);
const pads = replay.apply([pad(), { ...pad(), index: 1 }], 1010);
assert.deepEqual(pads.map(pressedIndices), [[], []]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
});
test('pads without the standard mapping are left alone', () => {
const replay = createControllerPressReplay();
replay.queue(0, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad([], '')], 1010)[0]), []);
});
@@ -0,0 +1,75 @@
import type { GamepadLike } from './gamepad-controller.js';
/**
* How far back a press the Gamepad API reported itself still counts as the same press the
* main process is replaying. Covers the helper -> main -> renderer IPC hop.
*/
const SAME_PRESS_WINDOW_MS = 500;
/**
* Replays macOS background presses (see `src/main/runtime/macos-controller-watch.ts`) into the
* controller poll. A queued button reads as pressed for one poll, so the controller sees a
* normal press edge; the next poll reads the real pad again, so a button still held does not
* fire twice. A press the Gamepad API already reported is dropped.
*
* Only a lone standard-mapping pad gets the replay: the press carries no pad identity, so with
* several visible it could run an action on the wrong one. With none visible yet, or a pad
* mapped differently, the press only served to focus the overlay.
*/
export function createControllerPressReplay() {
const pendingPresses: Array<{ buttonIndex: number; queuedAt: number }> = [];
const lastRealPressAt = new Map<number, number>();
function recordRealPresses(gamepads: GamepadLike[], now: number): void {
for (const gamepad of gamepads) {
gamepad.buttons.forEach((button, buttonIndex) => {
if (button.pressed) lastRealPressAt.set(buttonIndex, now);
});
}
}
function withPressed(gamepad: GamepadLike, buttonIndices: number[]): GamepadLike {
const buttons = gamepad.buttons.map((button, index) =>
buttonIndices.includes(index) ? { value: 1, pressed: true, touched: true } : button,
);
// Native Gamepad fields are prototype getters, so object spread would drop them.
return {
id: gamepad.id,
index: gamepad.index,
connected: gamepad.connected,
mapping: gamepad.mapping,
timestamp: gamepad.timestamp,
axes: gamepad.axes,
buttons,
};
}
return {
queue(buttonIndex: number, now: number): void {
pendingPresses.push({ buttonIndex, queuedAt: now });
},
/** Called once per poll with the Gamepad API's pads; returns the pads to read. */
apply(gamepads: Array<GamepadLike | null>, now: number): Array<GamepadLike | null> {
const connected = gamepads.filter((gamepad): gamepad is GamepadLike =>
Boolean(gamepad?.connected),
);
recordRealPresses(connected, now);
if (pendingPresses.length === 0) return gamepads;
const replayed = pendingPresses
.filter(({ buttonIndex, queuedAt }) => {
const seenAt = lastRealPressAt.get(buttonIndex);
return seenAt === undefined || seenAt < queuedAt - SAME_PRESS_WINDOW_MS;
})
.map(({ buttonIndex }) => buttonIndex);
pendingPresses.length = 0;
const onlyPad = connected.length === 1 ? connected[0] : undefined;
if (replayed.length === 0 || onlyPad?.mapping !== 'standard') return gamepads;
return gamepads.map((gamepad) =>
gamepad === onlyPad ? withPressed(onlyPad, replayed) : gamepad,
);
},
};
}