Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names (#312)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-01 15:54:47 -08:00
1 parent af44735bb8
commit 655510cf2e
24 files changed
+609 -33

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
+9
View File
@@ -159,6 +159,15 @@
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action. "buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "axisIndex": 0, // Raw axis index captured for this analog controller action.
+1 -1
View File
@@ -290,7 +290,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
### Controller support ### Controller support
Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts). Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode and the secondary subtitle hold (`controller.bindings.holdSecondarySub`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
| Key | Default | What it does | | Key | Default | What it does |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- | | ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
+9
View File
@@ -159,6 +159,15 @@
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action. "buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "axisIndex": 0, // Raw axis index captured for this analog controller action.
+21 -14
View File
@@ -157,25 +157,32 @@ You can drive the overlay with a gamepad.
1. Set `controller.enabled` to `true` in your config. 1. Set `controller.enabled` to `true` in your config.
2. Connect a controller. SubMiner uses the first one it sees. 2. Connect a controller. SubMiner uses the first one it sees.
3. Press `Y` on the controller to turn on keyboard-only mode. The controller only works in this mode. 3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it. 4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads. Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
| Button | Action | | Button | Action |
| --------------------- | ------------------------------------ | | --------------------- | -------------------------------------- |
| `A` (South) | Look up the selected word | | `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup | | `B` (East) | Close the lookup |
| `X` (West) | Mine a card | | `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode | | `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio | | `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source | | `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv | | `L3` | Pause or resume mpv |
| `Select` / `Minus` | Quit mpv | | `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup | | Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup | | Right stick (up/down) | Jump through the popup |
| Unbound by default | Show the secondary subtitle while held |
| Unbound by default | Previous word entry in the popup |
| Unbound by default | Next word entry in the popup |
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support). The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
## Development profiles ## Development profiles
+3
View File
@@ -79,6 +79,9 @@ export const CORE_DEFAULT_CONFIG: Pick<
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+18
View File
@@ -55,6 +55,24 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.controller.bindings.toggleMpvPause, defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding descriptor for toggling mpv play/pause.', description: 'Controller binding descriptor for toggling mpv play/pause.',
}, },
{
id: 'holdSecondarySub',
defaultValue: defaultConfig.controller.bindings.holdSecondarySub,
description:
'Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode.',
},
{
id: 'previousEntry',
defaultValue: defaultConfig.controller.bindings.previousEntry,
description:
'Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
{
id: 'nextEntry',
defaultValue: defaultConfig.controller.bindings.nextEntry,
description:
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
] as const; ] as const;
const axisBindings = [ const axisBindings = [
+3
View File
@@ -88,6 +88,9 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
'nextAudio', 'nextAudio',
'playCurrentAudio', 'playCurrentAudio',
'toggleMpvPause', 'toggleMpvPause',
'holdSecondarySub',
'previousEntry',
'nextEntry',
] as const; ] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [ const CONTROLLER_AXIS_BINDING_KEYS = [
+3
View File
@@ -70,6 +70,9 @@ function createControllerConfigFixture() {
nextAudio: { kind: 'button' as const, buttonIndex: 5 }, nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 }, playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 }, toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
holdSecondarySub: { kind: 'none' as const },
previousEntry: { kind: 'none' as const },
nextEntry: { kind: 'none' as const },
leftStickHorizontal: { leftStickHorizontal: {
kind: 'axis' as const, kind: 'axis' as const,
axisIndex: 0, axisIndex: 0,
@@ -67,6 +67,8 @@ const VOID_ACTIONS = [
'previousAudio', 'previousAudio',
'nextAudio', 'nextAudio',
'playCurrentAudio', 'playCurrentAudio',
'previousEntry',
'nextEntry',
'toggleMpvPause', 'toggleMpvPause',
] as const; ] as const;
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const; const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
@@ -87,6 +89,9 @@ function createHarness(
} = {}, } = {},
) { ) {
const calls: HarnessCall[] = []; const calls: HarnessCall[] = [];
// Hold changes are kept apart from `calls` so tests that press R2 for other
// actions keep their exact call lists.
const secondarySubHeld: boolean[] = [];
const states: ControllerRuntimeSnapshot[] = []; const states: ControllerRuntimeSnapshot[] = [];
const deps = options.deps ?? {}; const deps = options.deps ?? {};
const { gamepads = [], config = createControllerConfig() } = options; const { gamepads = [], config = createControllerConfig() } = options;
@@ -106,7 +111,10 @@ function createHarness(
previousAudio: () => {}, previousAudio: () => {},
nextAudio: () => {}, nextAudio: () => {},
playCurrentAudio: () => {}, playCurrentAudio: () => {},
previousEntry: () => {},
nextEntry: () => {},
toggleMpvPause: () => {}, toggleMpvPause: () => {},
setSecondarySubHeld: () => {},
scrollPopup: () => {}, scrollPopup: () => {},
jumpPopup: () => {}, jumpPopup: () => {},
...deps, ...deps,
@@ -121,6 +129,10 @@ function createHarness(
deps[action]?.(); deps[action]?.();
}; };
} }
controllerOptions.setSecondarySubHeld = (held) => {
secondarySubHeld.push(held);
deps.setSecondarySubHeld?.(held);
};
controllerOptions.moveSelection = (delta) => { controllerOptions.moveSelection = (delta) => {
calls.push(`moveSelection:${delta}`); calls.push(`moveSelection:${delta}`);
deps.moveSelection?.(delta); deps.moveSelection?.(delta);
@@ -137,6 +149,7 @@ function createHarness(
const controller = createGamepadController(controllerOptions); const controller = createGamepadController(controllerOptions);
return { return {
calls, calls,
secondarySubHeld,
states, states,
controller, controller,
poll: (...times: number[]) => { poll: (...times: number[]) => {
@@ -432,3 +445,217 @@ test('gamepad controller maps L3 to mpv pause and keeps unbound audio action ina
assert.deepEqual(harness.calls, ['toggleMpvPause']); assert.deepEqual(harness.calls, ['toggleMpvPause']);
}); });
const HOLD_ON_R2 = { holdSecondarySub: { kind: 'button', buttonIndex: 7 } } as const;
test('gamepad controller leaves the secondary subtitle hold unbound by default', () => {
const harness = createHarness({
gamepads: [
createGamepad('pad-1', {
buttons: Object.fromEntries(Array.from({ length: 16 }, (_, i) => [i, PRESSED])),
}),
],
});
harness.poll(0);
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.holdSecondarySub, { kind: 'none' });
assert.deepEqual(harness.secondarySubHeld, []);
});
test('gamepad controller shows the secondary subtitle only while R2 is held, even outside keyboard-only mode', () => {
let r2: TestButton = RELEASED;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 7: r2 } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getKeyboardModeEnabled: () => false },
});
harness.poll(0);
r2 = { value: 1, pressed: true, touched: true };
harness.poll(16, 32, 48);
r2 = RELEASED;
harness.poll(64, 80);
assert.deepEqual(harness.secondarySubHeld, [true, false]);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller releases the secondary subtitle hold when the pad disconnects', () => {
let connected = true;
const harness = createHarness({
gamepads: () => (connected ? [createGamepad('pad-1', { buttons: { 7: PRESSED } })] : []),
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
});
harness.poll(0);
connected = false;
harness.poll(16);
assert.deepEqual(harness.secondarySubHeld, [true, false]);
});
test('gamepad controller releases the secondary subtitle hold when a blocking modal opens', () => {
let blocked = false;
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
deps: { getInteractionBlocked: () => blocked },
});
harness.poll(0);
blocked = true;
harness.poll(16);
blocked = false;
harness.poll(32);
assert.deepEqual(harness.secondarySubHeld, [true, false, true]);
});
test('gamepad controller ignores the secondary subtitle hold while controller support is disabled', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ enabled: false, bindings: HOLD_ON_R2 }),
});
harness.poll(0, 16);
assert.deepEqual(harness.secondarySubHeld, []);
});
test('gamepad controller honors a remapped or explicitly unbound secondary subtitle hold', () => {
const remapped = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 5: PRESSED, 7: PRESSED } })],
config: createControllerConfig({
bindings: { holdSecondarySub: { kind: 'button', buttonIndex: 5 } },
}),
});
remapped.poll(0);
assert.deepEqual(remapped.secondarySubHeld, [true]);
const unbound = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 7: PRESSED } })],
config: createControllerConfig({ bindings: { holdSecondarySub: { kind: 'none' } } }),
});
unbound.poll(0);
assert.deepEqual(unbound.secondarySubHeld, []);
});
test('gamepad controller requires an analog R2 pull past the trigger deadzone to hold', () => {
let r2: TestButton = { value: 0.3, pressed: false, touched: true };
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: { 7: r2 } })],
config: createControllerConfig({
triggerInputMode: 'analog',
triggerDeadzone: 0.5,
bindings: HOLD_ON_R2,
}),
});
harness.poll(0);
assert.deepEqual(harness.secondarySubHeld, []);
r2 = { value: 0.7, pressed: false, touched: true };
harness.poll(16);
assert.deepEqual(harness.secondarySubHeld, [true]);
});
const ENTRY_ON_DPAD = {
previousEntry: { kind: 'button', buttonIndex: 12 },
nextEntry: { kind: 'button', buttonIndex: 13 },
// Free the D-pad from the popup-scroll fallback, as a user binding it would.
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'none' },
} as const;
test('gamepad controller leaves popup entry navigation unbound by default', () => {
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.previousEntry, { kind: 'none' });
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.nextEntry, { kind: 'none' });
});
test('gamepad controller moves one popup entry per press while the lookup is open', () => {
let buttons: Record<number, TestButton> = {};
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => true },
});
harness.poll(0);
buttons = { 13: PRESSED };
// Holding the button does not repeat.
harness.poll(16, 32, 400, 800);
buttons = {};
harness.poll(816);
buttons = { 13: PRESSED };
harness.poll(832);
buttons = {};
harness.poll(848);
buttons = { 12: PRESSED };
harness.poll(864);
assert.deepEqual(harness.calls, ['nextEntry', 'nextEntry', 'previousEntry']);
});
test('gamepad controller counts a popup press right as the popup reopens after a release', () => {
let buttons: Record<number, TestButton> = {};
let lookupOpen = true;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => lookupOpen },
});
harness.poll(0);
buttons = { 13: PRESSED };
harness.poll(16);
// The popup closes while the button is still down, and the button is let go meanwhile.
lookupOpen = false;
harness.poll(32);
buttons = {};
harness.poll(48);
// The popup reopens on the same frame the button is pressed again.
lookupOpen = true;
buttons = { 13: PRESSED };
harness.poll(64);
assert.deepEqual(harness.calls, ['nextEntry', 'nextEntry']);
});
test('gamepad controller waits for a fresh press when the popup opens under a held button', () => {
let lookupOpen = false;
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => lookupOpen },
});
harness.poll(0, 16);
lookupOpen = true;
harness.poll(32, 48);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller ignores popup entry navigation while the lookup is closed', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 12: PRESSED, 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => false },
});
harness.poll(0, 16);
assert.deepEqual(harness.calls, []);
});
test('gamepad controller requires keyboard-only mode for popup entry navigation', () => {
const harness = createHarness({
gamepads: [createGamepad('pad-1', { buttons: { 13: PRESSED } })],
config: createControllerConfig({ bindings: ENTRY_ON_DPAD }),
deps: { getLookupWindowOpen: () => true, getKeyboardModeEnabled: () => false },
});
harness.poll(0, 16);
assert.deepEqual(harness.calls, []);
});
@@ -37,7 +37,10 @@ type GamepadControllerOptions = {
previousAudio: () => void; previousAudio: () => void;
nextAudio: () => void; nextAudio: () => void;
playCurrentAudio: () => void; playCurrentAudio: () => void;
previousEntry: () => void;
nextEntry: () => void;
toggleMpvPause: () => void; toggleMpvPause: () => void;
setSecondarySubHeld: (held: boolean) => void;
scrollPopup: (deltaPixels: number) => void; scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void; jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void; onState: (state: ControllerRuntimeSnapshot) => void;
@@ -244,12 +247,22 @@ function resolveAxisBindingValue(
return axisValue; return axisValue;
} }
/** Discrete actions that only act while the lookup popup is open. */
const POPUP_ONLY_ACTIONS = [
'previousAudio',
'nextAudio',
'playCurrentAudio',
'previousEntry',
'nextEntry',
] as const;
export function createGamepadController(options: GamepadControllerOptions) { export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>(); let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState(); let selectionHold = createHoldState();
let jumpHold = createHoldState(); let jumpHold = createHoldState();
let activeGamepadId: string | null = null; let activeGamepadId: string | null = null;
let lastPollAt: number | null = null; let lastPollAt: number | null = null;
let secondarySubHeld = false;
function getConnectedGamepads(): GamepadLike[] { function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads()); return resolveConnectedGamepads(options.getGamepads());
@@ -290,6 +303,15 @@ export function createGamepadController(options: GamepadControllerOptions) {
}); });
} }
// Level-triggered: reports every change in held state, so releasing the button,
// disconnecting the pad, disabling the controller, or opening a blocking modal
// all hide the secondary subtitle again instead of leaving it stuck visible.
function syncSecondarySubHold(held: boolean): void {
if (held === secondarySubHeld) return;
secondarySubHeld = held;
options.setSecondarySubHeld(held);
}
function handleActionEdge( function handleActionEdge(
actionKey: string, actionKey: string,
binding: ResolvedControllerDiscreteBinding, binding: ResolvedControllerDiscreteBinding,
@@ -355,6 +377,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
['previousAudio', config.bindings.previousAudio], ['previousAudio', config.bindings.previousAudio],
['nextAudio', config.bindings.nextAudio], ['nextAudio', config.bindings.nextAudio],
['playCurrentAudio', config.bindings.playCurrentAudio], ['playCurrentAudio', config.bindings.playCurrentAudio],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleMpvPause', config.bindings.toggleMpvPause], ['toggleMpvPause', config.bindings.toggleMpvPause],
] as const; ] as const;
@@ -398,6 +422,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
publishState(connectedGamepads, activeGamepad); publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) { if (!activeGamepad) {
syncSecondarySubHold(false);
previousActions = new Map(); previousActions = new Map();
resetHeldAction(selectionHold); resetHeldAction(selectionHold);
resetHeldAction(jumpHold); resetHeldAction(jumpHold);
@@ -413,6 +438,18 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id); const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
// Unlike most actions, this one does not require keyboard-only mode, so it can be
// used while simply watching.
syncSecondarySubHold(
activeConfig.enabled &&
!options.getInteractionBlocked() &&
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings.holdSecondarySub,
activeConfig,
),
);
if (activeConfig.enabled) { if (activeConfig.enabled) {
handleActionEdge( handleActionEdge(
'toggleKeyboardOnlyMode', 'toggleKeyboardOnlyMode',
@@ -484,6 +521,20 @@ export function createGamepadController(options: GamepadControllerOptions) {
activeConfig, activeConfig,
options.playCurrentAudio, options.playCurrentAudio,
); );
handleActionEdge(
'previousEntry',
activeConfig.bindings.previousEntry,
activeGamepad,
activeConfig,
options.previousEntry,
);
handleActionEdge(
'nextEntry',
activeConfig.bindings.nextEntry,
activeGamepad,
activeConfig,
options.nextEntry,
);
const primaryScroll = resolveAxisBindingValue( const primaryScroll = resolveAxisBindingValue(
activeGamepad, activeGamepad,
@@ -509,6 +560,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
); );
} else { } else {
resetHeldAction(jumpHold); resetHeldAction(jumpHold);
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
// actions do: a release is not missed, and a button held as the popup opens waits for
// a fresh press instead of firing at once.
for (const actionKey of POPUP_ONLY_ACTIONS) {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings[actionKey],
activeConfig,
),
);
}
} }
handleActionEdge( handleActionEdge(
+29
View File
@@ -16,6 +16,7 @@ type CommandEventDetail = {
visible?: boolean; visible?: boolean;
key?: string; key?: string;
code?: string; code?: string;
modifiers?: string[];
repeat?: boolean; repeat?: boolean;
direction?: number; direction?: number;
deltaX?: number; deltaX?: number;
@@ -2525,3 +2526,31 @@ test('an unfinished built-in y chord cannot start a configured sequence', async
testGlobals.restore(); testGlobals.restore();
} }
}); });
test('controller popup entry navigation forwards Alt+Up / Alt+Down only while the popup is open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
assert.equal(handlers.movePopupEntryForController(1), false);
assert.equal(
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
false,
);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
assert.equal(handlers.movePopupEntryForController(1), true);
assert.equal(handlers.movePopupEntryForController(-1), true);
const forwarded = testGlobals.commandEvents
.filter((event) => event.type === 'forwardKeyDown')
.map((event) => [event.key, event.code, event.modifiers]);
assert.deepEqual(forwarded, [
['ArrowDown', 'ArrowDown', ['alt']],
['ArrowUp', 'ArrowUp', ['alt']],
]);
} finally {
testGlobals.restore();
}
});
+13
View File
@@ -828,6 +828,18 @@ export function createKeyboardHandlers(
return true; return true;
} }
// Sends the same Alt+Up / Alt+Down a keyboard user would press, which Yomitan's
// default hotkeys map to its previousEntry / nextEntry actions. Moving the entry
// also changes which entry "mine selected" mines.
function movePopupEntryForController(direction: -1 | 1): boolean {
if (!isLookupPopupOpen(ctx.state, document)) {
return false;
}
const key = direction < 0 ? 'ArrowUp' : 'ArrowDown';
dispatchYomitanPopupKeydown(key, key, ['alt'], false);
return true;
}
function playCurrentAudioForController(): boolean { function playCurrentAudioForController(): boolean {
if (!isLookupPopupOpen(ctx.state, document)) { if (!isLookupPopupOpen(ctx.state, document)) {
return false; return false;
@@ -1396,6 +1408,7 @@ export function createKeyboardHandlers(
mineSelectedFromController, mineSelectedFromController,
cyclePopupAudioSourceForController, cyclePopupAudioSourceForController,
playCurrentAudioForController, playCurrentAudioForController,
movePopupEntryForController,
scrollPopupByController, scrollPopupByController,
}; };
} }
@@ -16,6 +16,9 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -86,7 +89,10 @@ function findOne(root: FakeElement, className: string, text?: string): FakeEleme
} }
/** Renders the form into a fake container with `document` stubbed for the duration. */ /** Renders the form into a fake container with `document` stubbed for the duration. */
function renderForm(learningActionId: ActionId | null) { function renderForm(
learningActionId: ActionId | null,
options: { bindings?: typeof BINDINGS; mapping?: string } = {},
) {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
configurable: true, configurable: true,
@@ -98,7 +104,8 @@ function renderForm(learningActionId: ActionId | null) {
const container = new FakeElement(); const container = new FakeElement();
createControllerConfigForm({ createControllerConfigForm({
container: container as unknown as HTMLElement, container: container as unknown as HTMLElement,
getBindings: () => BINDINGS, getBindings: () => options.bindings ?? BINDINGS,
getControllerMapping: () => options.mapping,
getLearningActionId: () => learningActionId, getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => null, getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`), onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
@@ -141,9 +148,12 @@ test('controller config form renders grouped rows with friendly binding labels',
[ [
['Toggle Lookup', 'A / Cross'], ['Toggle Lookup', 'A / Cross'],
['Close Lookup', 'B / Circle'], ['Close Lookup', 'B / Circle'],
['Previous Entry', 'None'],
['Next Entry', 'None'],
['Mine Card', 'X / Square'], ['Mine Card', 'X / Square'],
['Toggle Keyboard-Only Mode', 'Y / Triangle'], ['Toggle Keyboard-Only Mode', 'Y / Triangle'],
['Toggle MPV Pause', 'R3 / RS'], ['Toggle MPV Pause', 'R3 / RS'],
['Show Secondary Subtitle (Hold)', 'None'],
['Quit MPV', 'Back / Select'], ['Quit MPV', 'Back / Select'],
['Previous Audio', 'None'], ['Previous Audio', 'None'],
['Next Audio', 'RB / R1'], ['Next Audio', 'RB / R1'],
@@ -160,7 +170,7 @@ test('controller config form renders grouped rows with friendly binding labels',
); );
assert.deepEqual( assert.deepEqual(
findAll(container, 'disabled').map((badge) => badge.textContent), findAll(container, 'disabled').map((badge) => badge.textContent),
['None', 'None', 'None'], ['None', 'None', 'None', 'None', 'None', 'None'],
); );
assert.deepEqual(findAll(container, 'expanded'), []); assert.deepEqual(findAll(container, 'expanded'), []);
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []); assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
@@ -227,3 +237,29 @@ for (const c of [
assert.deepEqual(calls, [c.expected]); assert.deepEqual(calls, [c.expected]);
}); });
} }
test('controller config form names buttons by the W3C standard layout for standard pads', () => {
const bindings: typeof BINDINGS = {
...BINDINGS,
quitMpv: { kind: 'button', buttonIndex: 6 },
toggleMpvPause: { kind: 'button', buttonIndex: 10 },
holdSecondarySub: { kind: 'button', buttonIndex: 11 },
previousEntry: { kind: 'button', buttonIndex: 8 },
nextEntry: { kind: 'button', buttonIndex: 9 },
rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
};
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
const standard = renderForm(null, { bindings, mapping: 'standard' });
assert.equal(badge(standard.row('Quit MPV')), 'LT / L2');
assert.equal(badge(standard.row('Previous Entry')), 'Back / Select');
assert.equal(badge(standard.row('Next Entry')), 'Start / Options');
assert.equal(badge(standard.row('Toggle MPV Pause')), 'L3 / LS');
assert.equal(badge(standard.row('Show Secondary Subtitle (Hold)')), 'R3 / RS');
assert.equal(badge(standard.row('Popup Jump (Stick)')), 'Right Stick Y');
// Pads without the standard mapping keep the previous names.
const raw = renderForm(null, { bindings, mapping: '' });
assert.equal(badge(raw.row('Quit MPV')), 'Back / Select');
assert.equal(badge(raw.row('Previous Entry')), 'L3 / LS');
});
+76 -13
View File
@@ -30,6 +30,20 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
bindingType: 'discrete', bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 1 }, defaultBinding: { kind: 'button', buttonIndex: 1 },
}, },
{
id: 'previousEntry',
label: 'Previous Entry',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'nextEntry',
label: 'Next Entry',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{ {
id: 'mineCard', id: 'mineCard',
label: 'Mine Card', label: 'Mine Card',
@@ -51,6 +65,13 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
bindingType: 'discrete', bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 9 }, defaultBinding: { kind: 'button', buttonIndex: 9 },
}, },
{
id: 'holdSecondarySub',
label: 'Show Secondary Subtitle (Hold)',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{ {
id: 'quitMpv', id: 'quitMpv',
label: 'Quit MPV', label: 'Quit MPV',
@@ -132,7 +153,9 @@ export function getDefaultDpadFallback(
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none'; return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
} }
const STANDARD_BUTTON_NAMES: Record<number, string> = { // Names for pads that do not report the W3C "standard" mapping. Raw layouts
// vary by driver, so these are a best guess.
const RAW_LAYOUT_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross', 0: 'A / Cross',
1: 'B / Circle', 1: 'B / Circle',
2: 'X / Square', 2: 'X / Square',
@@ -152,7 +175,7 @@ const STANDARD_BUTTON_NAMES: Record<number, string> = {
16: 'Guide / Home', 16: 'Guide / Home',
}; };
const STANDARD_AXIS_NAMES: Record<number, string> = { const RAW_LAYOUT_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X', 0: 'Left Stick X',
1: 'Left Stick Y', 1: 'Left Stick Y',
2: 'Left Trigger', 2: 'Left Trigger',
@@ -161,18 +184,52 @@ const STANDARD_AXIS_NAMES: Record<number, string> = {
5: 'Right Trigger', 5: 'Right Trigger',
}; };
// Names for pads that report `mapping === 'standard'` (most Xbox, PlayStation,
// Switch and 8BitDo pads). Index meanings are fixed by the W3C Gamepad spec:
// https://w3c.github.io/gamepad/#remapping
const W3C_STANDARD_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'LB / L1',
5: 'RB / R1',
6: 'LT / L2',
7: 'RT / R2',
8: 'Back / Select',
9: 'Start / Options',
10: 'L3 / LS',
11: 'R3 / RS',
12: 'D-pad Up',
13: 'D-pad Down',
14: 'D-pad Left',
15: 'D-pad Right',
16: 'Guide / Home',
};
const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X',
1: 'Left Stick Y',
2: 'Right Stick X',
3: 'Right Stick Y',
};
type ControllerMapping = string | null | undefined;
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = { const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None', none: 'None',
horizontal: 'D-pad \u2194', horizontal: 'D-pad \u2194',
vertical: 'D-pad \u2195', vertical: 'D-pad \u2195',
}; };
function getFriendlyButtonName(buttonIndex: number): string { export function getFriendlyButtonName(buttonIndex: number, mapping?: ControllerMapping): string {
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`; const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
return names[buttonIndex] ?? `Button ${buttonIndex}`;
} }
function getFriendlyAxisName(axisIndex: number): string { export function getFriendlyAxisName(axisIndex: number, mapping?: ControllerMapping): string {
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`; const names = mapping === 'standard' ? W3C_STANDARD_AXIS_NAMES : RAW_LAYOUT_AXIS_NAMES;
return names[axisIndex] ?? `Axis ${axisIndex}`;
} }
export function formatControllerBindingSummary( export function formatControllerBindingSummary(
@@ -193,21 +250,25 @@ export function formatControllerBindingSummary(
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`; return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
} }
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string { function formatFriendlyStickLabel(
binding: ResolvedControllerAxisBinding,
mapping: ControllerMapping,
): string {
if (binding.kind === 'none') return 'None'; if (binding.kind === 'none') return 'None';
return getFriendlyAxisName(binding.axisIndex); return getFriendlyAxisName(binding.axisIndex, mapping);
} }
function formatFriendlyBindingLabel( function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding, binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
mapping: ControllerMapping,
): string { ): string {
if (binding.kind === 'none') return 'None'; if (binding.kind === 'none') return 'None';
if ('direction' in binding) { if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex); const name = getFriendlyAxisName(binding.axisIndex, mapping);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`; return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
} }
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex); if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex, mapping);
return getFriendlyAxisName(binding.axisIndex); return getFriendlyAxisName(binding.axisIndex, mapping);
} }
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */ /** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
@@ -224,6 +285,8 @@ export function createControllerConfigForm(options: {
onDpadLearn: (actionId: ControllerBindingActionId) => void; onDpadLearn: (actionId: ControllerBindingActionId) => void;
onDpadClear: (actionId: ControllerBindingActionId) => void; onDpadClear: (actionId: ControllerBindingActionId) => void;
onDpadReset: (actionId: ControllerBindingActionId) => void; onDpadReset: (actionId: ControllerBindingActionId) => void;
/** Gamepad API `mapping` of the controller being configured; picks the button names. */
getControllerMapping?: () => ControllerMapping;
}) { }) {
let expandedRowKey: ExpandedRowKey | null = null; let expandedRowKey: ExpandedRowKey | null = null;
@@ -275,7 +338,7 @@ export function createControllerConfigForm(options: {
const row = createRow( const row = createRow(
definition.label, definition.label,
formatFriendlyBindingLabel(binding), formatFriendlyBindingLabel(binding, options.getControllerMapping?.()),
binding.kind === 'none', binding.kind === 'none',
isExpanded, isExpanded,
`Learn ${definition.label}`, `Learn ${definition.label}`,
@@ -329,7 +392,7 @@ export function createControllerConfigForm(options: {
const row = createRow( const row = createRow(
`${definition.label} (Stick)`, `${definition.label} (Stick)`,
formatFriendlyStickLabel(binding), formatFriendlyStickLabel(binding, options.getControllerMapping?.()),
binding.kind === 'none', binding.kind === 'none',
isExpanded, isExpanded,
`Learn ${definition.label} stick`, `Learn ${definition.label} stick`,
@@ -71,6 +71,9 @@ test('controller debug modal renders active controller axes, buttons, and config
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -186,6 +189,9 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -300,6 +306,9 @@ test('controller debug modal stays closed and notifies when controller support i
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -153,6 +153,9 @@ function buildContext() {
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+4
View File
@@ -56,6 +56,7 @@ export function createControllerSelectModal(
const controllerConfigForm = createControllerConfigForm({ const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList, container: ctx.dom.controllerConfigList,
getControllerMapping: () => getSelectedController()?.mapping ?? null,
getBindings: () => getBindings: () =>
getSelectedControllerConfig()?.bindings ?? { getSelectedControllerConfig()?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 }, toggleLookup: { kind: 'button', buttonIndex: 0 },
@@ -67,6 +68,9 @@ export function createControllerSelectModal(
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+13
View File
@@ -461,6 +461,9 @@ function startControllerPolling(): void {
nextAudio: { kind: 'button', buttonIndex: 5 }, nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -494,9 +497,19 @@ function startControllerPolling(): void {
playCurrentAudio: () => { playCurrentAudio: () => {
keyboardHandlers.playCurrentAudioForController(); keyboardHandlers.playCurrentAudioForController();
}, },
previousEntry: () => {
keyboardHandlers.movePopupEntryForController(-1);
},
nextEntry: () => {
keyboardHandlers.movePopupEntryForController(1);
},
toggleMpvPause: () => { toggleMpvPause: () => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']); window.electronAPI.sendMpvCommand(['cycle', 'pause']);
}, },
setSecondarySubHeld: (held) => {
subtitleRenderer.setSecondarySubHeld(held);
measurementReporter.schedule();
},
scrollPopup: (deltaPixels) => { scrollPopup: (deltaPixels) => {
emitControllerPopupScroll(deltaPixels); emitControllerPopupScroll(deltaPixels);
}, },
+26
View File
@@ -60,6 +60,12 @@ class FakeElement {
dataset: Record<string, string> = {}; dataset: Record<string, string> = {};
style = new FakeStyleDeclaration(); style = new FakeStyleDeclaration();
className = ''; className = '';
classes = new Set<string>();
classList = {
add: (...names: string[]) => names.forEach((name) => this.classes.add(name)),
remove: (...names: string[]) => names.forEach((name) => this.classes.delete(name)),
contains: (name: string) => this.classes.has(name),
};
replaceChildrenCalls = 0; replaceChildrenCalls = 0;
src?: string; src?: string;
alt?: string; alt?: string;
@@ -726,3 +732,23 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i
assert.deepEqual(prepareSecondarySubtitleLines(''), []); assert.deepEqual(prepareSecondarySubtitleLines(''), []);
assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []); assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []);
}); });
test('setSecondarySubHeld shows the secondary subtitle while held and restores the chosen mode', () => {
withRenderer({}, ({ renderer, dom }) => {
const modeClasses = () =>
[...dom.secondarySubContainer.classes].filter((name) => name.startsWith('secondary-sub-'));
renderer.updateSecondarySubMode('hidden');
assert.deepEqual(modeClasses(), ['secondary-sub-hidden']);
renderer.setSecondarySubHeld(true);
assert.deepEqual(modeClasses(), ['secondary-sub-visible']);
// A mode change from mpv or the CLI while held is remembered, not shown yet.
renderer.updateSecondarySubMode('hover');
assert.deepEqual(modeClasses(), ['secondary-sub-visible']);
renderer.setSecondarySubHeld(false);
assert.deepEqual(modeClasses(), ['secondary-sub-hover']);
});
});
+21 -2
View File
@@ -830,13 +830,31 @@ export function createSubtitleRenderer(ctx: RendererContext) {
} }
} }
function updateSecondarySubMode(mode: SecondarySubMode): void { // The mode chosen by the user (hidden / visible / hover). A controller hold
// temporarily shows it as `visible` without changing this, so releasing the
// button restores exactly what was there before.
let secondarySubBaseMode: SecondarySubMode = 'hidden';
let secondarySubHeld = false;
function applySecondarySubModeClass(): void {
const effectiveMode: SecondarySubMode = secondarySubHeld ? 'visible' : secondarySubBaseMode;
ctx.dom.secondarySubContainer.classList.remove( ctx.dom.secondarySubContainer.classList.remove(
'secondary-sub-hidden', 'secondary-sub-hidden',
'secondary-sub-visible', 'secondary-sub-visible',
'secondary-sub-hover', 'secondary-sub-hover',
); );
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`); ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${effectiveMode}`);
}
function updateSecondarySubMode(mode: SecondarySubMode): void {
secondarySubBaseMode = mode;
applySecondarySubModeClass();
}
function setSecondarySubHeld(held: boolean): void {
if (secondarySubHeld === held) return;
secondarySubHeld = held;
applySecondarySubModeClass();
} }
function updatePrimarySubMode(mode: PrimarySubMode): void { function updatePrimarySubMode(mode: PrimarySubMode): void {
@@ -1071,5 +1089,6 @@ export function createSubtitleRenderer(ctx: RendererContext) {
renderSubtitle, renderSubtitle,
updatePrimarySubMode, updatePrimarySubMode,
updateSecondarySubMode, updateSecondarySubMode,
setSecondarySubHeld,
}; };
} }
+3
View File
@@ -217,6 +217,9 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
'nextAudio', 'nextAudio',
'playCurrentAudio', 'playCurrentAudio',
'toggleMpvPause', 'toggleMpvPause',
'holdSecondarySub',
'previousEntry',
'nextEntry',
] as const; ] as const;
for (const key of discreteKeys) { for (const key of discreteKeys) {
if (value[key] === undefined) continue; if (value[key] === undefined) continue;
+6
View File
@@ -234,6 +234,9 @@ export interface ControllerBindingsConfig {
nextAudio?: ControllerDiscreteBindingConfig; nextAudio?: ControllerDiscreteBindingConfig;
playCurrentAudio?: ControllerDiscreteBindingConfig; playCurrentAudio?: ControllerDiscreteBindingConfig;
toggleMpvPause?: ControllerDiscreteBindingConfig; toggleMpvPause?: ControllerDiscreteBindingConfig;
holdSecondarySub?: ControllerDiscreteBindingConfig;
previousEntry?: ControllerDiscreteBindingConfig;
nextEntry?: ControllerDiscreteBindingConfig;
leftStickHorizontal?: ControllerAxisBindingConfig; leftStickHorizontal?: ControllerAxisBindingConfig;
leftStickVertical?: ControllerAxisBindingConfig; leftStickVertical?: ControllerAxisBindingConfig;
rightStickHorizontal?: ControllerAxisBindingConfig; rightStickHorizontal?: ControllerAxisBindingConfig;
@@ -250,6 +253,9 @@ export interface ResolvedControllerBindingsConfig {
nextAudio?: ResolvedControllerDiscreteBinding; nextAudio?: ResolvedControllerDiscreteBinding;
playCurrentAudio?: ResolvedControllerDiscreteBinding; playCurrentAudio?: ResolvedControllerDiscreteBinding;
toggleMpvPause?: ResolvedControllerDiscreteBinding; toggleMpvPause?: ResolvedControllerDiscreteBinding;
holdSecondarySub?: ResolvedControllerDiscreteBinding;
previousEntry?: ResolvedControllerDiscreteBinding;
nextEntry?: ResolvedControllerDiscreteBinding;
leftStickHorizontal?: ResolvedControllerAxisBinding; leftStickHorizontal?: ResolvedControllerAxisBinding;
leftStickVertical?: ResolvedControllerAxisBinding; leftStickVertical?: ResolvedControllerAxisBinding;
rightStickHorizontal?: ResolvedControllerAxisBinding; rightStickHorizontal?: ResolvedControllerAxisBinding;