fix(controller): keep popup pause and press handling reliable (#324)

This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 23:13:46 -08:00
1 parent 33f5f890d5
commit 7c637149dd
8 files changed
+213 -29

No files matched your search

@@ -20,12 +20,39 @@ function pad(pressed: number[] = [], mapping = 'standard'): GamepadLike {
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', () => {
test('a queued press the pad never reports is released after the window', () => {
const replay = createControllerPressReplay();
replay.queue(0, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1499)[0]), [0]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1500)[0]), []);
});
// The helper's IPC beats Chromium, which only starts reading the pad once the overlay has focus.
// Going unpressed before the late report would give the controller a second press edge.
test('a press the pad reports late reads as one continuous press', () => {
const replay = createControllerPressReplay();
replay.queue(11, 1000);
const pressedAt = (real: number[], now: number) =>
pressedIndices(replay.apply([pad(real)], now)[0]);
assert.deepEqual(pressedAt([], 1016), [11]);
assert.deepEqual(pressedAt([], 1032), [11]);
assert.deepEqual(pressedAt([11], 1048), [11]);
assert.deepEqual(pressedAt([11], 1064), [11]);
assert.deepEqual(pressedAt([], 1080), []);
});
test('a fresh press after the replayed one was released reads as a new press', () => {
const replay = createControllerPressReplay();
replay.queue(11, 1000);
assert.deepEqual(pressedIndices(replay.apply([pad([11])], 1016)[0]), [11]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1032)[0]), []);
assert.deepEqual(pressedIndices(replay.apply([pad([11])], 1048)[0]), [11]);
assert.deepEqual(pressedIndices(replay.apply([pad()], 1064)[0]), []);
});
test('a press the Gamepad API already reported is not replayed', () => {
@@ -1,16 +1,18 @@
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.
* How far apart the helper's report and the Gamepad API's report of one physical press can be.
* The Gamepad API can lead (the helper -> main -> renderer IPC hop) or lag (Chromium only reads
* the pad once the overlay has focus).
*/
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.
* controller poll. A queued button reads as pressed until the Gamepad API has reported that
* press and its release, or until the window passes if it never does. Holding it across the gap
* before a late report keeps one physical press to one press edge. 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
@@ -18,6 +20,7 @@ const SAME_PRESS_WINDOW_MS = 500;
*/
export function createControllerPressReplay() {
const pendingPresses: Array<{ buttonIndex: number; queuedAt: number }> = [];
const heldPresses = new Map<number, { releaseAt: number; realPressSeen: boolean }>();
const lastRealPressAt = new Map<number, number>();
function recordRealPresses(gamepads: GamepadLike[], now: number): void {
@@ -55,20 +58,37 @@ export function createControllerPressReplay() {
Boolean(gamepad?.connected),
);
recordRealPresses(connected, now);
if (pendingPresses.length === 0) return gamepads;
if (pendingPresses.length === 0 && heldPresses.size === 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);
for (const { buttonIndex, queuedAt } of pendingPresses) {
const seenAt = lastRealPressAt.get(buttonIndex);
if (seenAt === undefined || seenAt < queuedAt - SAME_PRESS_WINDOW_MS) {
heldPresses.set(buttonIndex, {
releaseAt: queuedAt + SAME_PRESS_WINDOW_MS,
realPressSeen: false,
});
}
}
pendingPresses.length = 0;
const onlyPad = connected.length === 1 ? connected[0] : undefined;
if (replayed.length === 0 || onlyPad?.mapping !== 'standard') return gamepads;
const onlyPad = connected.length === 1 ? connected[0] : undefined;
if (onlyPad?.mapping !== 'standard') {
heldPresses.clear();
return gamepads;
}
for (const [buttonIndex, hold] of heldPresses) {
if (onlyPad.buttons[buttonIndex]?.pressed) {
hold.realPressSeen = true;
} else if (hold.realPressSeen || now >= hold.releaseAt) {
heldPresses.delete(buttonIndex);
}
}
if (heldPresses.size === 0) return gamepads;
const held = [...heldPresses.keys()];
return gamepads.map((gamepad) =>
gamepad === onlyPad ? withPressed(onlyPad, replayed) : gamepad,
gamepad === onlyPad ? withPressed(onlyPad, held) : gamepad,
);
},
};
+64 -1
View File
@@ -147,7 +147,7 @@ function createMouseHarness(
/** Moves the tracked pointer onto `hovered` and fires a document mousemove. */
move: (hovered: HoverTarget, clientX = 120, clientY = 240) => {
harness.hovered = hovered;
harness.fire('document', 'mousemove', { clientX, clientY });
harness.fire('document', 'mousemove', { clientX, clientY, isTrusted: true });
},
isInteractive: () => overlayClassList.contains('interactive'),
isSecondaryHoverActive: () =>
@@ -357,6 +357,69 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
// The lookup's fallback scan dispatches untrusted mouse events on the selected word, and
// those bubble to the pointer tracker while the real cursor is elsewhere.
test('popup close resumes after a fallback scan event lands on the subtitle', async (t) => {
const h = createMouseHarness(t, { hoverPause: true, popupPause: true });
h.handlers.setupPointerTracking();
h.handlers.setupYomitanObserver();
h.hovered = 'primary';
h.fire('document', 'pointermove', { clientX: 120, clientY: 240, isTrusted: false });
h.fire('document', 'mousemove', { clientX: 120, clientY: 240, isTrusted: false });
await waitForNextTick();
assert.equal(h.ctx.state.isOverSubtitle, false);
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
await waitForNextTick();
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
// Controller and keyboard pause toggles change playback without touching the hover state.
test('popup open pauses when the user resumed playback during a hover pause', async (t) => {
let paused = false;
const h = createMouseHarness(t, {
hoverPause: true,
popupPause: true,
getPlaybackPaused: async () => paused,
});
h.handlers.setupYomitanObserver();
// `paused` stays false: the pause button resumed playback after this hover pause.
await h.handlers.handleMouseEnter();
assert.deepEqual(h.mpvCommands, [PAUSE]);
// The lookup event lands while the shown event's pause check is still in flight.
h.popupHostVisible = true;
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
h.fire('window', YOMITAN_LOOKUP_EVENT);
await waitForNextTick();
assert.deepEqual(h.mpvCommands, [PAUSE, PAUSE]);
});
test('popup taking over a hover pause leaves playback paused without a second command', async (t) => {
let paused = false;
const h = createMouseHarness(t, {
hoverPause: true,
popupPause: true,
getPlaybackPaused: async () => paused,
});
h.handlers.setupYomitanObserver();
await h.handlers.handleMouseEnter();
paused = true;
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
await waitForNextTick();
await h.handlers.handleMouseLeave();
assert.deepEqual(h.mpvCommands, [PAUSE]);
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
for (const c of [
{
name: 'nested popup close reasserts interactive state and focus when another popup remains visible on Windows',
+11 -9
View File
@@ -275,13 +275,14 @@ export function createMouseHandlers(
return;
}
const requestId = ++popupPauseRequestId;
if (pausedByYomitanPopup) return;
const requestId = ++popupPauseRequestId;
// The popup takes over the hover pause so that closing it owns the resume. A pause toggle
// can resume playback without clearing the hover flag, so mpv still decides below.
if (pausedBySubtitleHover) {
pausedBySubtitleHover = false;
pausedByYomitanPopup = true;
return;
}
let paused: boolean | null = null;
@@ -442,16 +443,17 @@ export function createMouseHandlers(
}
function setupPointerTracking(): void {
document.addEventListener('mousemove', (event: MouseEvent) => {
// The lookup's fallback scan dispatches untrusted events on the selected word, which say
// nothing about where the cursor is. Counting them as hover pauses playback until the next
// real pointer move.
const trackPointer = (event: MouseEvent | PointerEvent): void => {
if (!event.isTrusted) return;
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('pointermove', (event: PointerEvent) => {
updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event);
});
};
document.addEventListener('mousemove', trackPointer);
document.addEventListener('pointermove', trackPointer);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') {
return;
+6 -1
View File
@@ -657,7 +657,12 @@ function startControllerPolling(): void {
: ctx.state.kikuModalOpen
? fieldGroupingControllerLayout
: null,
getSuspended: () => ctx.state.controllerSuspendedByModalWindow,
// macOS and Windows keep the modal window's renderer alive while hidden, and it keeps
// polling. It only owns the controller with a modal open; otherwise every press would run
// here and in the main overlay.
getSuspended: () =>
ctx.state.controllerSuspendedByModalWindow ||
(ctx.platform.isModalLayer && !isAnyModalOpen()),
});
const poll = (now: number): void => {