Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 21:41:46 -08:00
1 parent 6d0f37ac21
commit b99409bbcd
60 files changed
+1900 -219

No files matched your search

@@ -15,6 +15,7 @@ import {
getControllerReviewInputContext,
} from './controller-screen-layout.js';
import type { GamepadLike } from './gamepad-controller.js';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
@@ -155,3 +156,33 @@ test('remapped duplicate card bindings replace the defaults', () => {
assert.deepEqual(actions, ['cancelMerge']);
});
test('dialog hints follow the button style of the controller in use', () => {
const config: ResolvedControllerConfig = {
...CONFIG,
profiles: {
'pad-ps': {
label: 'DualSense',
faceButtonLabels: 'playstation',
buttonIndices: CONFIG.buttonIndices,
bindings: CONFIG.bindings,
reviewBindings: CONFIG.reviewBindings,
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
},
},
};
const hintsFor = (gamepadId: string) => {
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
return formatControllerScreenHints(
CONTROLLER_FIELD_GROUPING_SCREEN,
padConfig.fieldGroupingBindings,
padConfig,
'standard',
);
};
assert.equal(hintsFor('pad-ps').continue, '╳');
assert.equal(hintsFor('pad-ps').toggleView, '△');
// Controllers without their own style use controller.faceButtonLabels.
assert.equal(hintsFor('other-pad').continue, 'A');
});
@@ -70,6 +70,10 @@ const VOID_ACTIONS = [
'previousEntry',
'nextEntry',
'toggleMpvPause',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
@@ -114,6 +118,10 @@ function createHarness(
previousEntry: () => {},
nextEntry: () => {},
toggleMpvPause: () => {},
previousSubtitle: () => {},
nextSubtitle: () => {},
replaySubtitle: () => {},
playNextSubtitle: () => {},
setSecondarySubHeld: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
@@ -288,6 +296,7 @@ test('gamepad controller uses active controller profile bindings before global b
profiles: {
'pad-profile': {
label: 'Profile Pad',
faceButtonLabels: globalConfig.faceButtonLabels,
buttonIndices: globalConfig.buttonIndices,
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
reviewBindings: globalConfig.reviewBindings,
@@ -427,7 +436,8 @@ for (const c of [
harness.poll(0);
assert.deepEqual(harness.calls, c.fired ? ['playCurrentAudio', 'toggleMpvPause'] : []);
// Pause works outside keyboard-only mode, so it is handled before the popup actions.
assert.deepEqual(harness.calls, c.fired ? ['toggleMpvPause', 'playCurrentAudio'] : []);
});
}
@@ -784,3 +794,99 @@ test('gamepad controller still ignores a button held when the screen opened', ()
assert.deepEqual(events, ['activate:pad-1:latch']);
});
const SUBTITLE_NAVIGATION_BINDINGS = {
previousSubtitle: { kind: 'button', buttonIndex: 10 },
nextSubtitle: { kind: 'button', buttonIndex: 11 },
replaySubtitle: { kind: 'button', buttonIndex: 8 },
playNextSubtitle: { kind: 'button', buttonIndex: 16 },
} as const;
test('gamepad controller leaves subtitle navigation unbound by default', () => {
for (const action of [
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const) {
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings[action], { kind: 'none' }, action);
}
});
test('gamepad controller navigates subtitles once per press, even outside keyboard-only mode', () => {
let pressed: number[] = [];
const harness = createHarness({
gamepads: () => [
createGamepad('pad-1', {
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
}),
],
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
deps: { getKeyboardModeEnabled: () => false },
});
harness.poll(0);
for (const index of [11, 10, 8, 16]) {
pressed = [index];
// Holding the button does not repeat.
harness.poll(16, 500, 1_000);
pressed = [];
harness.poll(1_016);
}
assert.deepEqual(harness.calls, [
'nextSubtitle',
'previousSubtitle',
'replaySubtitle',
'playNextSubtitle',
]);
});
test('gamepad controller pauses subtitle navigation while a blocking modal is open', () => {
let blocked = true;
let pressed = true;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 11: PRESSED } : {} })],
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
deps: { getInteractionBlocked: () => blocked },
});
harness.poll(0, 16);
// Still held when the modal closes: not a fresh press.
blocked = false;
harness.poll(32);
assert.deepEqual(harness.calls, []);
pressed = false;
harness.poll(48);
pressed = true;
harness.poll(64);
assert.deepEqual(harness.calls, ['nextSubtitle']);
});
test('gamepad controller pauses mpv outside keyboard-only mode, but not under a blocking modal', () => {
let blocked = false;
let pressed = false;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 9: PRESSED } : {} })],
config: createControllerConfig({
bindings: { toggleMpvPause: { kind: 'button', buttonIndex: 9 } },
}),
deps: {
getKeyboardModeEnabled: () => false,
getInteractionBlocked: () => blocked,
},
});
harness.poll(0);
pressed = true;
// Held: fires once.
harness.poll(16, 500);
pressed = false;
harness.poll(516);
blocked = true;
pressed = true;
harness.poll(532);
assert.deepEqual(harness.calls, ['toggleMpvPause']);
});
+34 -11
View File
@@ -18,6 +18,8 @@ export type GamepadLike = {
index: number;
connected: boolean;
mapping: string;
/** When the pad last reported new input (Gamepad API). */
timestamp?: number;
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
@@ -56,6 +58,10 @@ type GamepadControllerOptions = {
previousEntry: () => void;
nextEntry: () => void;
toggleMpvPause: () => void;
previousSubtitle: () => void;
nextSubtitle: () => void;
replaySubtitle: () => void;
playNextSubtitle: () => void;
setSecondarySubHeld: (held: boolean) => void;
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
@@ -73,14 +79,16 @@ export type HoldState = {
initialFired: boolean;
};
const DPAD_BUTTON_INDEX = {
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
export const DPAD_BUTTON_INDEX = {
up: 12,
down: 13,
left: 14,
right: 15,
} as const;
const DPAD_AXIS_INDEX = {
/** Axes some raw (non-standard) pads use for the D-pad. */
export const DPAD_AXIS_INDEX = {
horizontal: 6,
vertical: 7,
} as const;
@@ -276,6 +284,15 @@ const POPUP_ONLY_ACTIONS = [
'nextEntry',
] as const;
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
const WATCHING_ACTIONS = [
'toggleMpvPause',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState();
@@ -312,6 +329,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
index: gamepad.index,
mapping: gamepad.mapping,
connected: gamepad.connected,
timestamp: gamepad.timestamp,
})) satisfies ControllerDeviceInfo[],
activeGamepadId,
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
@@ -401,7 +419,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
['playCurrentAudio', config.bindings.playCurrentAudio],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleMpvPause', config.bindings.toggleMpvPause],
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
] as const;
for (const [actionKey, binding] of discreteActions) {
@@ -503,6 +521,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
),
);
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
if (activeConfig.enabled && !options.getInteractionBlocked()) {
for (const actionKey of WATCHING_ACTIONS) {
handleActionEdge(
actionKey,
activeConfig.bindings[actionKey],
activeGamepad,
activeConfig,
options[actionKey],
);
}
}
if (activeConfig.enabled) {
handleActionEdge(
'toggleKeyboardOnlyMode',
@@ -628,14 +659,6 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
}
handleActionEdge(
'toggleMpvPause',
activeConfig.bindings.toggleMpvPause,
activeGamepad,
activeConfig,
options.toggleMpvPause,
);
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
+61 -1
View File
@@ -538,10 +538,15 @@ function createKeyboardHandlerHarness() {
let changelogKeydownCount = 0;
let jimakuKeydownCount = 0;
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
let wordEventCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
dispatchEvent: () => true,
dispatchEvent: () => {
wordEventCount += 1;
return true;
},
});
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
@@ -618,6 +623,7 @@ function createKeyboardHandlerHarness() {
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
changelogKeydownCount: () => changelogKeydownCount,
jimakuKeydownCount: () => jimakuKeydownCount,
wordEventCount: () => wordEventCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -2554,3 +2560,57 @@ test('controller popup entry navigation forwards Alt+Up / Alt+Down only while th
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// Without a popup, the lookup falls back to a synthetic scan shortly after.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
await wait(100);
const afterFallback = wordEventCount();
assert.ok(afterFallback > 0, 'the fallback scan runs');
// Leaving the mode before that fallback runs must cancel it.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
handlers.handleKeyboardModeToggleRequested();
await wait(100);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode closes an open popup', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
const closeEvents = () =>
testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(closeEvents().length, 1);
// Turning the mode on again leaves a popup alone.
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(closeEvents().length, 1);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
+13 -4
View File
@@ -41,6 +41,8 @@ export function createKeyboardHandlers(
openControllerSelectModal?: () => void;
openControllerDebugModal?: () => void;
getPlaybackPaused: () => Promise<boolean | null>;
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
toggleSubtitleSidebarModal?: () => void;
},
) {
@@ -550,6 +552,7 @@ export function createKeyboardHandlers(
}
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
const changed = ctx.state.keyboardDrivenModeEnabled !== enabled;
ctx.state.keyboardDrivenModeEnabled = enabled;
ctx.state.keyboardSelectionVisible = enabled;
if (!enabled) {
@@ -560,6 +563,10 @@ export function createKeyboardHandlers(
clearNativeSubtitleSelection();
}
syncKeyboardTokenSelection();
// Leaving the mode means going back to watching: close the popup it was used for.
// Done after the mode is off, so focus is not pulled back to the overlay.
if (changed && !enabled) closeLookupWindow();
if (changed) options.onKeyboardDrivenModeChanged?.(enabled);
}
function toggleKeyboardDrivenMode(): void {
@@ -756,6 +763,12 @@ export function createKeyboardHandlers(
// released) and attention without a pane, so callers like the Kiku modal
// opener leave nothing behind.
function closeLookupWindow(): boolean {
// Also when no popup shows yet: a lookup that has not opened one must not open it
// later through its fallback scan (for example after leaving keyboard-only mode).
if (lookupScanFallbackTimer !== null) {
clearTimeout(lookupScanFallbackTimer);
lookupScanFallbackTimer = null;
}
if (
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
@@ -764,10 +777,6 @@ export function createKeyboardHandlers(
return false;
}
if (lookupScanFallbackTimer !== null) {
clearTimeout(lookupScanFallbackTimer);
lookupScanFallbackTimer = null;
}
dispatchYomitanPopupVisibility(false);
dispatchYomitanFrontendClearActiveTextSource();
clearNativeSubtitleSelection();
+15
View File
@@ -509,6 +509,21 @@ test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
});
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
h.handlers.setupYomitanObserver();
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
// The popup hides under the pointer: hidden, then the leave it causes.
h.popupHostVisible = false;
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
assert.equal(h.ctx.state.yomitanPopupVisible, false);
assert.equal(h.ctx.state.isOverYomitanPopup, false);
assert.equal(h.isInteractive(), false);
});
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
const h = createMouseHarness(t);
h.ctx.state.primaryVisibleOnYomitanPopup = true;
+4 -1
View File
@@ -505,7 +505,10 @@ export function createMouseHandlers(
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
ctx.state.isOverYomitanPopup = false;
reconcilePopupInteraction({ assumeVisible: true });
// A popup that hides under a resting pointer also reports a leave, right after its
// hidden event. Only keep a popup marked open if it still was; otherwise the next
// keyboard or controller word move would look it up again and reopen it.
reconcilePopupInteraction({ assumeVisible: yomitanPopupVisible });
});
window.addEventListener('focus', () => {