mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
fix(controller): pick up macOS presses while mpv is in front (#321)
This commit is contained in:
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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user