mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-13 04:46:08 -08:00
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:
1 parent
af44735bb8
commit
655510cf2e
24 files changed
+609
-33
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user