diff --git a/changes/controller-shortcuts.md b/changes/controller-shortcuts.md new file mode 100644 index 00000000..b4346b68 --- /dev/null +++ b/changes/controller-shortcuts.md @@ -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. diff --git a/changes/controller-standard-button-names.md b/changes/controller-standard-button-names.md new file mode 100644 index 00000000..7ed9d134 --- /dev/null +++ b/changes/controller-standard-button-names.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index cf2a4e8c..2344fce4 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -159,6 +159,15 @@ "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. }, // 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": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index ddbdb91a..2cc280d2 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -290,7 +290,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w ### 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 | | ---------------------------------- | -------- | ------------------------------------------------------------------------------------- | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index cf2a4e8c..2344fce4 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -159,6 +159,15 @@ "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. }, // 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": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. diff --git a/docs-site/usage.md b/docs-site/usage.md index f32dcb6f..45875150 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -157,25 +157,32 @@ You can drive the overlay with a gamepad. 1. Set `controller.enabled` to `true` in your config. 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. 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 | -| --------------------- | ------------------------------------ | -| `A` (South) | Look up the selected word | -| `B` (East) | Close the lookup | -| `X` (West) | Mine a card | -| `Y` (North) | Toggle keyboard-only mode | -| `L1` | Play the current Yomitan audio | -| `R1` | Next Yomitan audio source | -| `L3` | Pause or resume mpv | -| `Select` / `Minus` | Quit mpv | -| Left stick | Move between words, scroll the popup | -| Right stick (up/down) | Jump through the popup | +| Button | Action | +| --------------------- | -------------------------------------- | +| `A` (South) | Look up the selected word | +| `B` (East) | Close the lookup | +| `X` (West) | Mine a card | +| `Y` (North) | Toggle keyboard-only mode | +| `L1` | Play the current Yomitan audio | +| `R1` | Next Yomitan audio source | +| `L3` | Pause or resume mpv | +| `Select` / `Minus` | Quit mpv | +| Left stick | Move between words, scroll 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 diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 9d823db2..82c17690 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -79,6 +79,9 @@ export const CORE_DEFAULT_CONFIG: Pick< nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 8166d91a..5499366b 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -55,6 +55,24 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.controller.bindings.toggleMpvPause, 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; const axisBindings = [ diff --git a/src/config/resolve/controller.ts b/src/config/resolve/controller.ts index 95f01a0e..0d7f91b4 100644 --- a/src/config/resolve/controller.ts +++ b/src/config/resolve/controller.ts @@ -88,6 +88,9 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [ 'nextAudio', 'playCurrentAudio', 'toggleMpvPause', + 'holdSecondarySub', + 'previousEntry', + 'nextEntry', ] as const; const CONTROLLER_AXIS_BINDING_KEYS = [ diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 0f4c0ea2..aa934980 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -70,6 +70,9 @@ function createControllerConfigFixture() { nextAudio: { kind: 'button' as const, buttonIndex: 5 }, playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 }, toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 }, + holdSecondarySub: { kind: 'none' as const }, + previousEntry: { kind: 'none' as const }, + nextEntry: { kind: 'none' as const }, leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index 4d40165d..6f2b1c90 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -67,6 +67,8 @@ const VOID_ACTIONS = [ 'previousAudio', 'nextAudio', 'playCurrentAudio', + 'previousEntry', + 'nextEntry', 'toggleMpvPause', ] as const; const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const; @@ -87,6 +89,9 @@ function createHarness( } = {}, ) { 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 deps = options.deps ?? {}; const { gamepads = [], config = createControllerConfig() } = options; @@ -106,7 +111,10 @@ function createHarness( previousAudio: () => {}, nextAudio: () => {}, playCurrentAudio: () => {}, + previousEntry: () => {}, + nextEntry: () => {}, toggleMpvPause: () => {}, + setSecondarySubHeld: () => {}, scrollPopup: () => {}, jumpPopup: () => {}, ...deps, @@ -121,6 +129,10 @@ function createHarness( deps[action]?.(); }; } + controllerOptions.setSecondarySubHeld = (held) => { + secondarySubHeld.push(held); + deps.setSecondarySubHeld?.(held); + }; controllerOptions.moveSelection = (delta) => { calls.push(`moveSelection:${delta}`); deps.moveSelection?.(delta); @@ -137,6 +149,7 @@ function createHarness( const controller = createGamepadController(controllerOptions); return { calls, + secondarySubHeld, states, controller, 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']); }); + +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 = {}; + 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 = {}; + 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, []); +}); diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index dddb37b1..df67563b 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -37,7 +37,10 @@ type GamepadControllerOptions = { previousAudio: () => void; nextAudio: () => void; playCurrentAudio: () => void; + previousEntry: () => void; + nextEntry: () => void; toggleMpvPause: () => void; + setSecondarySubHeld: (held: boolean) => void; scrollPopup: (deltaPixels: number) => void; jumpPopup: (deltaPixels: number) => void; onState: (state: ControllerRuntimeSnapshot) => void; @@ -244,12 +247,22 @@ function resolveAxisBindingValue( 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) { let previousActions = new Map(); let selectionHold = createHoldState(); let jumpHold = createHoldState(); let activeGamepadId: string | null = null; let lastPollAt: number | null = null; + let secondarySubHeld = false; function getConnectedGamepads(): GamepadLike[] { 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( actionKey: string, binding: ResolvedControllerDiscreteBinding, @@ -355,6 +377,8 @@ export function createGamepadController(options: GamepadControllerOptions) { ['previousAudio', config.bindings.previousAudio], ['nextAudio', config.bindings.nextAudio], ['playCurrentAudio', config.bindings.playCurrentAudio], + ['previousEntry', config.bindings.previousEntry], + ['nextEntry', config.bindings.nextEntry], ['toggleMpvPause', config.bindings.toggleMpvPause], ] as const; @@ -398,6 +422,7 @@ export function createGamepadController(options: GamepadControllerOptions) { publishState(connectedGamepads, activeGamepad); if (!activeGamepad) { + syncSecondarySubHold(false); previousActions = new Map(); resetHeldAction(selectionHold); resetHeldAction(jumpHold); @@ -413,6 +438,18 @@ export function createGamepadController(options: GamepadControllerOptions) { 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) { handleActionEdge( 'toggleKeyboardOnlyMode', @@ -484,6 +521,20 @@ export function createGamepadController(options: GamepadControllerOptions) { activeConfig, options.playCurrentAudio, ); + handleActionEdge( + 'previousEntry', + activeConfig.bindings.previousEntry, + activeGamepad, + activeConfig, + options.previousEntry, + ); + handleActionEdge( + 'nextEntry', + activeConfig.bindings.nextEntry, + activeGamepad, + activeConfig, + options.nextEntry, + ); const primaryScroll = resolveAxisBindingValue( activeGamepad, @@ -509,6 +560,19 @@ export function createGamepadController(options: GamepadControllerOptions) { ); } else { 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( diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index aa4b5f7d..6b3a550a 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -16,6 +16,7 @@ type CommandEventDetail = { visible?: boolean; key?: string; code?: string; + modifiers?: string[]; repeat?: boolean; direction?: number; deltaX?: number; @@ -2525,3 +2526,31 @@ test('an unfinished built-in y chord cannot start a configured sequence', async 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(); + } +}); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index ce3e5c08..b2399697 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -828,6 +828,18 @@ export function createKeyboardHandlers( 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 { if (!isLookupPopupOpen(ctx.state, document)) { return false; @@ -1396,6 +1408,7 @@ export function createKeyboardHandlers( mineSelectedFromController, cyclePopupAudioSourceForController, playCurrentAudioForController, + movePopupEntryForController, scrollPopupByController, }; } diff --git a/src/renderer/modals/controller-config-form.test.ts b/src/renderer/modals/controller-config-form.test.ts index e9b18444..806a81f4 100644 --- a/src/renderer/modals/controller-config-form.test.ts +++ b/src/renderer/modals/controller-config-form.test.ts @@ -16,6 +16,9 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = { nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, 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. */ -function renderForm(learningActionId: ActionId | null) { +function renderForm( + learningActionId: ActionId | null, + options: { bindings?: typeof BINDINGS; mapping?: string } = {}, +) { const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); Object.defineProperty(globalThis, 'document', { configurable: true, @@ -98,7 +104,8 @@ function renderForm(learningActionId: ActionId | null) { const container = new FakeElement(); createControllerConfigForm({ container: container as unknown as HTMLElement, - getBindings: () => BINDINGS, + getBindings: () => options.bindings ?? BINDINGS, + getControllerMapping: () => options.mapping, getLearningActionId: () => learningActionId, getDpadLearningActionId: () => null, 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'], ['Close Lookup', 'B / Circle'], + ['Previous Entry', 'None'], + ['Next Entry', 'None'], ['Mine Card', 'X / Square'], ['Toggle Keyboard-Only Mode', 'Y / Triangle'], ['Toggle MPV Pause', 'R3 / RS'], + ['Show Secondary Subtitle (Hold)', 'None'], ['Quit MPV', 'Back / Select'], ['Previous Audio', 'None'], ['Next Audio', 'RB / R1'], @@ -160,7 +170,7 @@ test('controller config form renders grouped rows with friendly binding labels', ); assert.deepEqual( 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, 'controller-config-edit-panel'), []); @@ -227,3 +237,29 @@ for (const c of [ 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'); +}); diff --git a/src/renderer/modals/controller-config-form.ts b/src/renderer/modals/controller-config-form.ts index f30ef7c6..914ed16d 100644 --- a/src/renderer/modals/controller-config-form.ts +++ b/src/renderer/modals/controller-config-form.ts @@ -30,6 +30,20 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [ bindingType: 'discrete', 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', label: 'Mine Card', @@ -51,6 +65,13 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [ bindingType: 'discrete', defaultBinding: { kind: 'button', buttonIndex: 9 }, }, + { + id: 'holdSecondarySub', + label: 'Show Secondary Subtitle (Hold)', + group: 'Playback', + bindingType: 'discrete', + defaultBinding: { kind: 'none' }, + }, { id: 'quitMpv', label: 'Quit MPV', @@ -132,7 +153,9 @@ export function getDefaultDpadFallback( return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none'; } -const STANDARD_BUTTON_NAMES: Record = { +// 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 = { 0: 'A / Cross', 1: 'B / Circle', 2: 'X / Square', @@ -152,7 +175,7 @@ const STANDARD_BUTTON_NAMES: Record = { 16: 'Guide / Home', }; -const STANDARD_AXIS_NAMES: Record = { +const RAW_LAYOUT_AXIS_NAMES: Record = { 0: 'Left Stick X', 1: 'Left Stick Y', 2: 'Left Trigger', @@ -161,18 +184,52 @@ const STANDARD_AXIS_NAMES: Record = { 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 = { + 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 = { + 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 = { none: 'None', horizontal: 'D-pad \u2194', vertical: 'D-pad \u2195', }; -function getFriendlyButtonName(buttonIndex: number): string { - return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`; +export function getFriendlyButtonName(buttonIndex: number, mapping?: ControllerMapping): string { + const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES; + return names[buttonIndex] ?? `Button ${buttonIndex}`; } -function getFriendlyAxisName(axisIndex: number): string { - return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`; +export function getFriendlyAxisName(axisIndex: number, mapping?: ControllerMapping): string { + const names = mapping === 'standard' ? W3C_STANDARD_AXIS_NAMES : RAW_LAYOUT_AXIS_NAMES; + return names[axisIndex] ?? `Axis ${axisIndex}`; } export function formatControllerBindingSummary( @@ -193,21 +250,25 @@ export function formatControllerBindingSummary( 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'; - return getFriendlyAxisName(binding.axisIndex); + return getFriendlyAxisName(binding.axisIndex, mapping); } function formatFriendlyBindingLabel( binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding, + mapping: ControllerMapping, ): string { if (binding.kind === 'none') return 'None'; if ('direction' in binding) { - const name = getFriendlyAxisName(binding.axisIndex); + const name = getFriendlyAxisName(binding.axisIndex, mapping); return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`; } - if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex); - return getFriendlyAxisName(binding.axisIndex); + if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex, mapping); + return getFriendlyAxisName(binding.axisIndex, mapping); } /** 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; onDpadClear: (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; @@ -275,7 +338,7 @@ export function createControllerConfigForm(options: { const row = createRow( definition.label, - formatFriendlyBindingLabel(binding), + formatFriendlyBindingLabel(binding, options.getControllerMapping?.()), binding.kind === 'none', isExpanded, `Learn ${definition.label}`, @@ -329,7 +392,7 @@ export function createControllerConfigForm(options: { const row = createRow( `${definition.label} (Stick)`, - formatFriendlyStickLabel(binding), + formatFriendlyStickLabel(binding, options.getControllerMapping?.()), binding.kind === 'none', isExpanded, `Learn ${definition.label} stick`, diff --git a/src/renderer/modals/controller-debug.test.ts b/src/renderer/modals/controller-debug.test.ts index 5f2f3a84..605216df 100644 --- a/src/renderer/modals/controller-debug.test.ts +++ b/src/renderer/modals/controller-debug.test.ts @@ -71,6 +71,9 @@ test('controller debug modal renders active controller axes, buttons, and config nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, 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 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, 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 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index fa7b6074..b8280a21 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -153,6 +153,9 @@ function buildContext() { nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index 1d929a6f..e6d4afc6 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -56,6 +56,7 @@ export function createControllerSelectModal( const controllerConfigForm = createControllerConfigForm({ container: ctx.dom.controllerConfigList, + getControllerMapping: () => getSelectedController()?.mapping ?? null, getBindings: () => getSelectedControllerConfig()?.bindings ?? { toggleLookup: { kind: 'button', buttonIndex: 0 }, @@ -67,6 +68,9 @@ export function createControllerSelectModal( nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 9cc09422..9f53baeb 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -461,6 +461,9 @@ function startControllerPolling(): void { nextAudio: { kind: 'button', buttonIndex: 5 }, playCurrentAudio: { kind: 'button', buttonIndex: 4 }, toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + holdSecondarySub: { kind: 'none' }, + previousEntry: { kind: 'none' }, + nextEntry: { kind: 'none' }, leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, @@ -494,9 +497,19 @@ function startControllerPolling(): void { playCurrentAudio: () => { keyboardHandlers.playCurrentAudioForController(); }, + previousEntry: () => { + keyboardHandlers.movePopupEntryForController(-1); + }, + nextEntry: () => { + keyboardHandlers.movePopupEntryForController(1); + }, toggleMpvPause: () => { window.electronAPI.sendMpvCommand(['cycle', 'pause']); }, + setSecondarySubHeld: (held) => { + subtitleRenderer.setSecondarySubHeld(held); + measurementReporter.schedule(); + }, scrollPopup: (deltaPixels) => { emitControllerPopupScroll(deltaPixels); }, diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index cc37933b..12524744 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -60,6 +60,12 @@ class FakeElement { dataset: Record = {}; style = new FakeStyleDeclaration(); className = ''; + classes = new Set(); + 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; src?: string; alt?: string; @@ -726,3 +732,23 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i assert.deepEqual(prepareSecondarySubtitleLines(''), []); 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']); + }); +}); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index bccfc829..cfae26e9 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -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( 'secondary-sub-hidden', 'secondary-sub-visible', '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 { @@ -1071,5 +1089,6 @@ export function createSubtitleRenderer(ctx: RendererContext) { renderSubtitle, updatePrimarySubMode, updateSecondarySubMode, + setSecondarySubHeld, }; } diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index c622897c..e3ade2b2 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -217,6 +217,9 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin 'nextAudio', 'playCurrentAudio', 'toggleMpvPause', + 'holdSecondarySub', + 'previousEntry', + 'nextEntry', ] as const; for (const key of discreteKeys) { if (value[key] === undefined) continue; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index b31db46b..4cd40138 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -234,6 +234,9 @@ export interface ControllerBindingsConfig { nextAudio?: ControllerDiscreteBindingConfig; playCurrentAudio?: ControllerDiscreteBindingConfig; toggleMpvPause?: ControllerDiscreteBindingConfig; + holdSecondarySub?: ControllerDiscreteBindingConfig; + previousEntry?: ControllerDiscreteBindingConfig; + nextEntry?: ControllerDiscreteBindingConfig; leftStickHorizontal?: ControllerAxisBindingConfig; leftStickVertical?: ControllerAxisBindingConfig; rightStickHorizontal?: ControllerAxisBindingConfig; @@ -250,6 +253,9 @@ export interface ResolvedControllerBindingsConfig { nextAudio?: ResolvedControllerDiscreteBinding; playCurrentAudio?: ResolvedControllerDiscreteBinding; toggleMpvPause?: ResolvedControllerDiscreteBinding; + holdSecondarySub?: ResolvedControllerDiscreteBinding; + previousEntry?: ResolvedControllerDiscreteBinding; + nextEntry?: ResolvedControllerDiscreteBinding; leftStickHorizontal?: ResolvedControllerAxisBinding; leftStickVertical?: ResolvedControllerAxisBinding; rightStickHorizontal?: ResolvedControllerAxisBinding;