Add overlay gamepad support for keyboard-only mode (#17)

This commit is contained in:
2026-03-11 20:34:46 -07:00
committed by GitHub
parent 2f17859b7b
commit 4d7c80f2e4
49 changed files with 5677 additions and 42 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## v0.6.0 (2026-03-12)
### Added
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
### Internal
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
## v0.5.6 (2026-03-10) ## v0.5.6 (2026-03-10)
### Fixed ### Fixed

View File

@@ -0,0 +1,74 @@
---
id: TASK-159
title: Add overlay controller support for keyboard-only mode
status: Done
assignee:
- codex
created_date: '2026-03-11 00:30'
updated_date: '2026-03-11 04:05'
labels:
- enhancement
- renderer
- overlay
- input
dependencies:
- TASK-86
references:
- src/renderer/handlers/keyboard.ts
- src/renderer/renderer.ts
- src/renderer/state.ts
- src/renderer/index.html
- src/renderer/style.css
- src/preload.ts
- src/types.ts
- src/config/definitions/defaults-core.ts
- src/config/definitions/options-core.ts
- src/config/definitions/template-sections.ts
- config.example.jsonc
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
<!-- AC:END -->
## Implementation Notes
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
- Added a transient in-overlay controller status indicator when a controller is first detected.
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
## Verification
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
- `bun run generate:config-example`
- `bun run typecheck`
- `bun run docs:test`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run docs:build`
- `bun run test:smoke:dist`

View File

@@ -0,0 +1,7 @@
type: added
area: overlay
- Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
- Added a transient in-overlay controller-detected indicator when a controller is first found.
- Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.

View File

@@ -50,6 +50,55 @@
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ==========================================
// Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active.
// Use the selection modal to save a preferred controller by id for future launches.
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
"select": 6, // Raw button index used for the controller select/minus/back button.
"buttonSouth": 0, // Raw button index used for controller south/A button input.
"buttonEast": 1, // Raw button index used for controller east/B button input.
"buttonWest": 2, // Raw button index used for controller west/X button input.
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
"leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
"bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
} // Bindings setting.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================
// Startup Warmups // Startup Warmups
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.6.0 (2026-03-12)
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
- Added smooth, slower popup scrolling for controller navigation.
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
- Added a transient in-overlay controller-detected indicator when a controller is first found.
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
## v0.5.6 (2026-03-10) ## v0.5.6 (2026-03-10)
- Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails. - Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails.
- Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`. - Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`.

View File

@@ -95,6 +95,7 @@ The configuration file includes several main sections:
- [**Keybindings**](#keybindings) - MPV command shortcuts - [**Keybindings**](#keybindings) - MPV command shortcuts
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts - [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows - [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference - [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
@@ -503,6 +504,88 @@ Set any shortcut to `null` to disable it.
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled. Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
### Controller Support
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- `Alt+C` opens the controller selection modal and saves the selected controller for future launches.
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
```jsonc
{
"controller": {
"enabled": true,
"preferredGamepadId": "",
"preferredGamepadLabel": "",
"smoothScroll": true,
"scrollPixelsPerSecond": 900,
"horizontalJumpPixels": 160,
"stickDeadzone": 0.2,
"triggerInputMode": "auto",
"triggerDeadzone": 0.5,
"repeatDelayMs": 320,
"repeatIntervalMs": 120,
"buttonIndices": {
"select": 6,
"buttonSouth": 0,
"buttonEast": 1,
"buttonWest": 2,
"buttonNorth": 3,
"leftShoulder": 4,
"rightShoulder": 5,
"leftStickPress": 9,
"rightStickPress": 10,
"leftTrigger": 6,
"rightTrigger": 7
},
"bindings": {
"toggleLookup": "buttonSouth",
"closeLookup": "buttonEast",
"toggleKeyboardOnlyMode": "buttonNorth",
"mineCard": "buttonWest",
"quitMpv": "select",
"previousAudio": "none",
"nextAudio": "rightShoulder",
"playCurrentAudio": "leftShoulder",
"toggleMpvPause": "leftStickPress",
"leftStickHorizontal": "leftStickX",
"leftStickVertical": "leftStickY",
"rightStickHorizontal": "rightStickX",
"rightStickVertical": "rightStickY"
}
}
}
```
Default logical mapping:
- Left stick up/down: scroll Yomitan popup
- Left stick left/right: move subtitle token selection
- Right stick up/down: page-jump through Yomitan popup
- Right stick left/right: unused by default
- `A`: toggle lookup
- `B`: close lookup
- `Y`: toggle keyboard-only mode
- `X`: mine card
- `Minus` / `Select`: quit mpv
- `L1`: play current Yomitan audio (falls back to the first available track)
- `R1`: move to the next available Yomitan audio track
- `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts ### Manual Card Update Shortcuts
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:

View File

@@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
3. Yomitan detects the selection and opens its lookup popup. 3. Yomitan detects the selection and opens its lookup popup.
4. From the popup, add the word to Anki. 4. From the popup, add the word to Anki.
### Controller Workflow
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
## Creating Anki Cards ## Creating Anki Cards
There are three ways to create cards, depending on your workflow. There are three ways to create cards, depending on your workflow.

View File

@@ -50,6 +50,55 @@
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ==========================================
// Controller Support
// Gamepad support for the visible overlay while keyboard-only mode is active.
// Use the selection modal to save a preferred controller by id for future launches.
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
"select": 6, // Raw button index used for the controller select/minus/back button.
"buttonSouth": 0, // Raw button index used for controller south/A button input.
"buttonEast": 1, // Raw button index used for controller east/B button input.
"buttonWest": 2, // Raw button index used for controller west/X button input.
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
"leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Button indices setting.
"bindings": {
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
} // Bindings setting.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================
// Startup Warmups // Startup Warmups
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.

View File

@@ -69,6 +69,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
## Controller Shortcuts
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
| Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ |
| `Alt+C` | Open controller selection modal | Fixed |
| `Alt+Shift+C` | Open controller debug modal | Fixed |
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
## MPV Plugin Chords ## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second. When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.

View File

@@ -246,6 +246,45 @@ Notes:
- `--whisper-threads` - `--whisper-threads`
- `--yt-subgen-audio-format` - `--yt-subgen-audio-format`
## Controller Support
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup.
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values.
### Default Button Mapping
| Button | Action |
| ------ | ------ |
| `A` (South) | Toggle lookup |
| `B` (East) | Close lookup |
| `Y` (North) | Toggle keyboard-only mode |
| `X` (West) | Mine card |
| `L1` | Play current Yomitan audio |
| `R1` | Next Yomitan audio track |
| `L3` (left stick press) | Toggle mpv pause |
| `Select` / `Minus` | Quit mpv |
| `L2` / `R2` | Unbound (available for custom bindings) |
### Analog Controls
| Input | Action |
| ----- | ------ |
| Left stick horizontal | Move token selection left/right |
| Left stick vertical | Smooth scroll Yomitan popup |
| Right stick horizontal | Jump inside popup (horizontal) |
| Right stick vertical | Smooth scroll popup (vertical) |
| D-pad | Fallback for stick navigation |
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
## Keybindings ## Keybindings
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization. See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.

View File

@@ -0,0 +1,110 @@
# Overlay Controller Support Design
**Date:** 2026-03-11
**Backlog:** `TASK-159`
## Goal
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
## Scope
- Poll connected gamepads from the visible overlay renderer.
- Default to the first connected controller unless config specifies a preferred controller.
- Add logical controller bindings and tuning knobs to config.
- Add `Alt+C` controller selection modal.
- Add `Alt+Shift+C` controller debug modal.
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
Out of scope for this pass:
- Raw arbitrary axis/button index remapping in config.
- Controller support outside the visible overlay renderer.
- Haptics or vibration.
## Architecture
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
## Behavior
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
Default logical mappings:
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
- left stick horizontal: move token selection left/right
- right stick vertical: smooth Yomitan popup/window scroll
- right stick horizontal: jump horizontally inside Yomitan popup/window
- `A`: toggle lookup
- `B`: close lookup
- `Y`: toggle keyboard-only mode
- `X`: mine card
- `L1` / `R1`: previous / next Yomitan audio
- `R2`: activate current Yomitan audio button
- `L2`: toggle mpv play/pause
Selection-highlight cleanup:
- disabling keyboard-only mode clears the selected token class immediately
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
## Config
Add a top-level `controller` block in resolved config with:
- `enabled`
- `preferredGamepadId`
- `preferredGamepadLabel`
- `smoothScroll`
- `scrollPixelsPerSecond`
- `horizontalJumpPixels`
- `stickDeadzone`
- `triggerDeadzone`
- `repeatDelayMs`
- `repeatIntervalMs`
- `bindings` logical fields for the named actions/sticks
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
## UI
Controller selection modal:
- overlay-hosted modal in the visible renderer
- lists currently connected controllers
- highlights current active choice
- selecting one persists config and makes it the active controller immediately if connected
Controller debug modal:
- overlay-hosted modal
- shows selected controller and all connected controllers
- live raw axis array values
- live raw button values, pressed flags, and touched flags if available
## Testing
Test first:
- controller gating outside keyboard-only mode
- logical mapping to existing helpers
- continuous stick scroll and repeat behavior
- modal open shortcuts
- preferred-controller selection persistence
- highlight cleanup on keyboard-only disable and popup close
- config defaults/parse/template generation coverage
## Risks
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
Mitigation: match by exact preferred id first; fall back to first connected controller.
- Continuous stick input can spam actions.
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
- Popup DOM/audio controls may vary.
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.

View File

@@ -0,0 +1,245 @@
# Overlay Controller Support Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
---
### Task 1: Track work and lock the design
**Files:**
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
**Step 1: Record the approved scope**
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
**Step 2: Verify the written scope matches the approved design**
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
### Task 2: Add failing config tests and defaults
**Files:**
- Modify: `src/config/config.test.ts`
- Modify: `src/config/definitions/defaults-core.ts`
- Modify: `src/config/definitions/options-core.ts`
- Modify: `src/config/definitions/template-sections.ts`
- Modify: `src/types.ts`
- Modify: `config.example.jsonc`
**Step 1: Write the failing test**
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
**Step 2: Run test to verify it fails**
Run: `bun test src/config/config.test.ts`
Expected: FAIL because `controller` config is not defined yet.
**Step 3: Write minimal implementation**
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
**Step 4: Run test to verify it passes**
Run: `bun test src/config/config.test.ts`
Expected: PASS
### Task 3: Add failing keyboard-selection cleanup tests
**Files:**
- Modify: `src/renderer/handlers/keyboard.test.ts`
- Modify: `src/renderer/handlers/keyboard.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- turning keyboard-only mode off clears `.keyboard-selected`
- closing the popup clears stale selection highlight when keyboard-only mode is off
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: FAIL because selection cleanup is incomplete today.
**Step 3: Write minimal implementation**
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/keyboard.test.ts`
Expected: PASS
### Task 4: Add failing controller runtime tests
**Files:**
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
- Create: `src/renderer/handlers/gamepad-controller.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
- Modify: `src/renderer/renderer.ts`
**Step 1: Write the failing tests**
Cover:
- first connected controller is selected by default
- preferred controller wins when connected
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
- stick/button mappings invoke the expected logical helpers
- smooth scroll and repeat throttling behavior
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: FAIL because controller runtime does not exist.
**Step 3: Write minimal implementation**
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
Expected: PASS
### Task 5: Add failing controller modal tests
**Files:**
- Modify: `src/renderer/index.html`
- Modify: `src/renderer/style.css`
- Create: `src/renderer/modals/controller-select.ts`
- Create: `src/renderer/modals/controller-select.test.ts`
- Create: `src/renderer/modals/controller-debug.ts`
- Create: `src/renderer/modals/controller-debug.test.ts`
- Modify: `src/renderer/renderer.ts`
- Modify: `src/renderer/context.ts`
- Modify: `src/renderer/state.ts`
**Step 1: Write the failing tests**
Add tests for:
- `Alt+C` opens controller selection modal
- `Alt+Shift+C` opens controller debug modal
- selection modal renders connected controllers and persists the chosen device
- debug modal shows live axes/buttons state
**Step 2: Run test to verify it fails**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: FAIL because modals and shortcuts do not exist.
**Step 3: Write minimal implementation**
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
**Step 4: Run test to verify it passes**
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
Expected: PASS
### Task 6: Persist controller preference through preload/main wiring
**Files:**
- Modify: `src/preload.ts`
- Modify: `src/types.ts`
- Modify: `src/shared/ipc/contracts.ts`
- Modify: `src/core/services/ipc.ts`
- Modify: `src/main.ts`
- Modify: related main/runtime tests as needed
**Step 1: Write the failing test**
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
**Step 2: Run test to verify it fails**
Run: `bun test src/core/services/ipc.test.ts`
Expected: FAIL because no controller preference IPC exists yet.
**Step 3: Write minimal implementation**
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
**Step 4: Run test to verify it passes**
Run: `bun test src/core/services/ipc.test.ts`
Expected: PASS
### Task 7: Update docs and config example
**Files:**
- Modify: `config.example.jsonc`
- Modify: `README.md`
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
**Step 1: Write the failing doc/config check if needed**
If config example generation is covered by tests, add/refresh the failing assertion first.
**Step 2: Implement the docs**
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
**Step 3: Run doc/config verification**
Run: `bun run test:config`
Expected: PASS
### Task 8: Run the handoff gate and update the backlog task
**Files:**
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
**Step 1: Run targeted verification**
Run:
- `bun test src/config/config.test.ts`
- `bun test src/renderer/handlers/keyboard.test.ts`
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
- `bun test src/renderer/modals/controller-select.test.ts`
- `bun test src/renderer/modals/controller-debug.test.ts`
- `bun test src/core/services/ipc.test.ts`
Expected: PASS
**Step 2: Run broader gate**
Run:
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
Expected: PASS, or document exact blockers/failures.
**Step 3: Update backlog notes**
Fill in implementation notes, verification commands, and final summary in `TASK-159`.

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.5.6", "version": "0.6.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -1106,6 +1106,135 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.youtubeSubgen.fixWithAi, true); assert.equal(config.youtubeSubgen.fixWithAi, true);
}); });
test('parses controller settings with logical bindings and tuning knobs', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"enabled": true,
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
"preferredGamepadLabel": "Xbox Wireless Controller",
"smoothScroll": false,
"scrollPixelsPerSecond": 1440,
"horizontalJumpPixels": 180,
"stickDeadzone": 0.3,
"triggerInputMode": "analog",
"triggerDeadzone": 0.4,
"repeatDelayMs": 220,
"repeatIntervalMs": 70,
"buttonIndices": {
"select": 6,
"leftStickPress": 9,
"rightStickPress": 10
},
"bindings": {
"toggleLookup": "buttonWest",
"closeLookup": "buttonEast",
"toggleKeyboardOnlyMode": "buttonNorth",
"mineCard": "buttonSouth",
"quitMpv": "select",
"previousAudio": "leftShoulder",
"nextAudio": "rightShoulder",
"playCurrentAudio": "none",
"toggleMpvPause": "leftStickPress",
"leftStickHorizontal": "rightStickX",
"leftStickVertical": "rightStickY",
"rightStickHorizontal": "leftStickX",
"rightStickVertical": "leftStickY"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.controller.enabled, true);
assert.equal(
config.controller.preferredGamepadId,
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
);
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
assert.equal(config.controller.smoothScroll, false);
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
assert.equal(config.controller.horizontalJumpPixels, 180);
assert.equal(config.controller.stickDeadzone, 0.3);
assert.equal(config.controller.triggerInputMode, 'analog');
assert.equal(config.controller.triggerDeadzone, 0.4);
assert.equal(config.controller.repeatDelayMs, 220);
assert.equal(config.controller.repeatIntervalMs, 70);
assert.equal(config.controller.buttonIndices.select, 6);
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
assert.equal(config.controller.bindings.quitMpv, 'select');
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
});
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"scrollPixelsPerSecond": 0.5,
"horizontalJumpPixels": 0.2,
"repeatDelayMs": 0.9,
"repeatIntervalMs": 0.1
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
});
test('controller button index config rejects fractional values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"select": 6.5,
"leftStickPress": 9.1
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
assert.equal(
config.controller.buttonIndices.leftStickPress,
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
true,
);
});
test('runtime options registry is centralized', () => { test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [ assert.deepEqual(ids, [
@@ -1638,6 +1767,7 @@ test('template generator includes known keys', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG); const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ai":/); assert.match(output, /"ai":/);
assert.match(output, /"ankiConnect":/); assert.match(output, /"ankiConnect":/);
assert.match(output, /"controller":/);
assert.match(output, /"logging":/); assert.match(output, /"logging":/);
assert.match(output, /"websocket":/); assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/); assert.match(output, /"discordPresence":/);
@@ -1662,6 +1792,14 @@ test('template generator includes known keys', () => {
output, output,
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/, /"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
); );
assert.match(
output,
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
);
assert.match(
output,
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match( assert.match(
output, output,

View File

@@ -25,6 +25,7 @@ const {
annotationWebsocket, annotationWebsocket,
logging, logging,
texthooker, texthooker,
controller,
shortcuts, shortcuts,
secondarySub, secondarySub,
subsync, subsync,
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
annotationWebsocket, annotationWebsocket,
logging, logging,
texthooker, texthooker,
controller,
ankiConnect, ankiConnect,
shortcuts, shortcuts,
secondarySub, secondarySub,

View File

@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'annotationWebsocket' | 'annotationWebsocket'
| 'logging' | 'logging'
| 'texthooker' | 'texthooker'
| 'controller'
| 'shortcuts' | 'shortcuts'
| 'secondarySub' | 'secondarySub'
| 'subsync' | 'subsync'
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
launchAtStartup: true, launchAtStartup: true,
openBrowser: true, openBrowser: true,
}, },
controller: {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
},
shortcuts: { shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O', toggleVisibleOverlayGlobal: 'Alt+Shift+O',
copySubtitle: 'CommandOrControl+C', copySubtitle: 'CommandOrControl+C',

View File

@@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [ for (const requiredPath of [
'logging.level', 'logging.level',
'annotationWebsocket.enabled', 'annotationWebsocket.enabled',
'controller.enabled',
'controller.scrollPixelsPerSecond',
'startupWarmups.lowPowerMode', 'startupWarmups.lowPowerMode',
'subtitleStyle.enableJlpt', 'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup', 'subtitleStyle.autoPauseVideoOnYomitanPopup',
@@ -38,6 +40,7 @@ test('config template sections include expected domains and unique keys', () =>
const requiredKeys: (typeof keys)[number][] = [ const requiredKeys: (typeof keys)[number][] = [
'websocket', 'websocket',
'annotationWebsocket', 'annotationWebsocket',
'controller',
'startupWarmups', 'startupWarmups',
'subtitleStyle', 'subtitleStyle',
'ankiConnect', 'ankiConnect',

View File

@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry( export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig, defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] { ): ConfigOptionRegistryEntry[] {
const controllerButtonEnumValues = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
];
return [ return [
{ {
path: 'logging.level', path: 'logging.level',
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level, defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.', description: 'Minimum log level for runtime logging.',
}, },
{
path: 'controller.enabled',
kind: 'boolean',
defaultValue: defaultConfig.controller.enabled,
description: 'Enable overlay controller support through the Chrome Gamepad API.',
},
{
path: 'controller.preferredGamepadId',
kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadId,
description: 'Preferred controller id saved from the controller selection modal.',
},
{
path: 'controller.preferredGamepadLabel',
kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadLabel,
description: 'Preferred controller display label saved for diagnostics.',
},
{
path: 'controller.smoothScroll',
kind: 'boolean',
defaultValue: defaultConfig.controller.smoothScroll,
description: 'Use smooth scrolling for controller-driven popup scroll input.',
},
{
path: 'controller.scrollPixelsPerSecond',
kind: 'number',
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
description: 'Base popup scroll speed for controller stick input.',
},
{
path: 'controller.horizontalJumpPixels',
kind: 'number',
defaultValue: defaultConfig.controller.horizontalJumpPixels,
description: 'Popup page-jump distance for controller jump input.',
},
{
path: 'controller.stickDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.stickDeadzone,
description: 'Deadzone applied to controller stick axes.',
},
{
path: 'controller.triggerInputMode',
kind: 'enum',
enumValues: ['auto', 'digital', 'analog'],
defaultValue: defaultConfig.controller.triggerInputMode,
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
},
{
path: 'controller.triggerDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.triggerDeadzone,
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
},
{
path: 'controller.repeatDelayMs',
kind: 'number',
defaultValue: defaultConfig.controller.repeatDelayMs,
description: 'Delay before repeating held controller actions.',
},
{
path: 'controller.repeatIntervalMs',
kind: 'number',
defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.',
},
{
path: 'controller.buttonIndices.select',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.select,
description: 'Raw button index used for the controller select/minus/back button.',
},
{
path: 'controller.buttonIndices.buttonSouth',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
description: 'Raw button index used for controller south/A button input.',
},
{
path: 'controller.buttonIndices.buttonEast',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
description: 'Raw button index used for controller east/B button input.',
},
{
path: 'controller.buttonIndices.buttonNorth',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
description: 'Raw button index used for controller north/Y button input.',
},
{
path: 'controller.buttonIndices.buttonWest',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
description: 'Raw button index used for controller west/X button input.',
},
{
path: 'controller.buttonIndices.leftShoulder',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
description: 'Raw button index used for controller left shoulder input.',
},
{
path: 'controller.buttonIndices.rightShoulder',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
description: 'Raw button index used for controller right shoulder input.',
},
{
path: 'controller.buttonIndices.leftStickPress',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
description: 'Raw button index used for controller L3 input.',
},
{
path: 'controller.buttonIndices.rightStickPress',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
description: 'Raw button index used for controller R3 input.',
},
{
path: 'controller.buttonIndices.leftTrigger',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
description: 'Raw button index used for controller L2 input.',
},
{
path: 'controller.buttonIndices.rightTrigger',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
description: 'Raw button index used for controller R2 input.',
},
{
path: 'controller.bindings.toggleLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleLookup,
description: 'Controller binding for toggling lookup.',
},
{
path: 'controller.bindings.closeLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.closeLookup,
description: 'Controller binding for closing lookup.',
},
{
path: 'controller.bindings.toggleKeyboardOnlyMode',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
description: 'Controller binding for toggling keyboard-only mode.',
},
{
path: 'controller.bindings.mineCard',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.mineCard,
description: 'Controller binding for mining the active card.',
},
{
path: 'controller.bindings.quitMpv',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.quitMpv,
description: 'Controller binding for quitting mpv.',
},
{
path: 'controller.bindings.previousAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.previousAudio,
description: 'Controller binding for previous Yomitan audio.',
},
{
path: 'controller.bindings.nextAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.nextAudio,
description: 'Controller binding for next Yomitan audio.',
},
{
path: 'controller.bindings.playCurrentAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
description: 'Controller binding for playing the current Yomitan audio.',
},
{
path: 'controller.bindings.toggleMpvPause',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding for toggling mpv play/pause.',
},
{
path: 'controller.bindings.leftStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
description: 'Axis binding used for left/right token selection.',
},
{
path: 'controller.bindings.leftStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
description: 'Axis binding used for primary popup scrolling.',
},
{
path: 'controller.bindings.rightStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
description: 'Axis binding reserved for alternate right-stick mappings.',
},
{
path: 'controller.bindings.rightStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
description: 'Axis binding used for popup page jumps.',
},
{ {
path: 'texthooker.launchAtStartup', path: 'texthooker.launchAtStartup',
kind: 'boolean', kind: 'boolean',

View File

@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
key: 'logging', key: 'logging',
}, },
{
title: 'Controller Support',
description: [
'Gamepad support for the visible overlay while keyboard-only mode is active.',
'Use the selection modal to save a preferred controller by id for future launches.',
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
],
key: 'controller',
},
{ {
title: 'Startup Warmups', title: 'Startup Warmups',
description: [ description: [

View File

@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void { export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
const controllerButtonBindings = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
if (isObject(src.texthooker)) { if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
} }
} }
if (isObject(src.controller)) {
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
}
}
if (isObject(src.controller.buttonIndices)) {
const buttonIndexKeys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of buttonIndexKeys) {
const value = asNumber(src.controller.buttonIndices[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
resolved.controller.buttonIndices[key] = value;
} else if (src.controller.buttonIndices[key] !== undefined) {
warn(
`controller.buttonIndices.${key}`,
src.controller.buttonIndices[key],
resolved.controller.buttonIndices[key],
'Expected non-negative integer.',
);
}
}
}
if (isObject(src.controller.bindings)) {
const buttonBindingKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of buttonBindingKeys) {
const value = asString(src.controller.bindings[key]);
if (
value !== undefined &&
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
resolved.controller.bindings[key],
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
);
}
}
const axisBindingKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisBindingKeys) {
const value = asString(src.controller.bindings[key]);
if (
value !== undefined &&
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
resolved.controller.bindings[key],
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
);
}
}
}
}
if (Array.isArray(src.keybindings)) { if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter( resolved.keybindings = src.keybindings.filter(
(entry): entry is { key: string; command: (string | number)[] | null } => { (entry): entry is { key: string; command: (string | number)[] | null } => {

View File

@@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getMpvClient: () => null, getMpvClient: () => null,
focusMainWindow: () => {}, focusMainWindow: () => {},
@@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '', getCurrentSecondarySub: () => '',
focusMainWindow: () => {}, focusMainWindow: () => {},
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused); const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
assert.ok(getPlaybackPausedHandler); assert.ok(getPlaybackPausedHandler);
assert.equal(getPlaybackPausedHandler!({}), null); assert.equal(getPlaybackPausedHandler!({}), null);
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
assert.ok(getControllerConfigHandler);
assert.equal(
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
960,
);
}); });
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = []; const saves: unknown[] = [];
const controllerSaves: unknown[] = [];
const closedModals: unknown[] = []; const closedModals: unknown[] = [];
const openedModals: unknown[] = []; const openedModals: unknown[] = [];
registerIpcHandlers( registerIpcHandlers(
@@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
saveControllerPreference: (update) => {
controllerSaves.push(update);
},
getSecondarySubMode: () => 'hover', getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '', getCurrentSecondarySub: () => '',
focusMainWindow: () => {}, focusMainWindow: () => {},
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
assert.deepEqual(openedModals, ['subsync', 'runtime-options']); assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
}); });
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
saveControllerPreference: async (update) => {
await Promise.resolve();
controllerSaves.push(update);
},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
assert.ok(saveHandler);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
await saveHandler!({}, {
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
});
assert.deepEqual(controllerSaves, [
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getControllerConfig: () => ({
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
}),
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
await assert.rejects(
async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
},
/Invalid controller preference payload/,
);
});

View File

@@ -1,6 +1,8 @@
import electron from 'electron'; import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { IpcMainEvent } from 'electron';
import type { import type {
ControllerPreferenceUpdate,
ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
RuntimeOptionValue, RuntimeOptionValue,
SubtitlePosition, SubtitlePosition,
@@ -10,6 +12,7 @@ import type {
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import { import {
parseMpvCommand, parseMpvCommand,
parseControllerPreferenceUpdate,
parseOptionalForwardingOptions, parseOptionalForwardingOptions,
parseOverlayHostedModal, parseOverlayHostedModal,
parseRuntimeOptionDirection, parseRuntimeOptionDirection,
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string; getCurrentSecondarySub: () => string;
focusMainWindow: () => void; focusMainWindow: () => void;
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
getSecondarySubMode: () => unknown; getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void; focusMainWindow: () => void;
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
handleMpvCommand: options.handleMpvCommand, handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getConfiguredShortcuts: options.getConfiguredShortcuts, getConfiguredShortcuts: options.getConfiguredShortcuts,
getControllerConfig: options.getControllerConfig,
saveControllerPreference: options.saveControllerPreference,
getSecondarySubMode: options.getSecondarySubMode, getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => { focusMainWindow: () => {
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.saveSubtitlePosition(parsedPosition); deps.saveSubtitlePosition(parsedPosition);
}); });
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerPreferenceUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller preference payload');
}
await deps.saveControllerPreference(parsedUpdate);
});
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus(); return deps.getMecabStatus();
}); });
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getConfiguredShortcuts(); return deps.getConfiguredShortcuts();
}); });
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
return deps.getControllerConfig();
});
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => { ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
return deps.getSecondarySubMode(); return deps.getSecondarySubMode();
}); });

View File

@@ -358,7 +358,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -3407,6 +3408,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getControllerConfig: () => getResolvedConfig().controller,
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
configService.patchRawConfig({
controller: {
preferredGamepadId,
preferredGamepadLabel,
},
});
},
getSecondarySubMode: () => appState.secondarySubMode, getSecondarySubMode: () => appState.secondarySubMode,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getAnkiConnectStatus: () => appState.ankiIntegration !== null, getAnkiConnectStatus: () => appState.ankiIntegration !== null,

View File

@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual']; runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
getControllerConfig: params.getControllerConfig,
saveControllerPreference: params.saveControllerPreference,
focusMainWindow: params.focusMainWindow ?? (() => {}), focusMainWindow: params.focusMainWindow ?? (() => {}),
getSecondarySubMode: params.getSecondarySubMode, getSecondarySubMode: params.getSecondarySubMode,
getMpvClient: params.getMpvClient, getMpvClient: params.getMpvClient,

View File

@@ -1,10 +1,9 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
export interface OverlayWindowResolver { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null;
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
}; };
} }
export type { OverlayHostedModal };

View File

@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
getControllerConfig: () => ({}) as never,
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover' as never, getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null, getMpvClient: () => null,
getAnkiConnectStatus: () => false, getAnkiConnectStatus: () => false,

View File

@@ -1,4 +1,4 @@
import type { OverlayHostedModal } from '../overlay-runtime'; import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
export function createSetOverlayVisibleHandler(deps: { export function createSetOverlayVisibleHandler(deps: {

View File

@@ -1,5 +1,5 @@
import type { RuntimeOptionState } from '../../types'; import type { RuntimeOptionState } from '../../types';
import type { OverlayHostedModal } from '../overlay-runtime'; import type { OverlayHostedModal } from '../../shared/ipc/contracts';
type RuntimeOptionsManagerLike = { type RuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[]; listOptions: () => RuntimeOptionState[];

View File

@@ -48,6 +48,8 @@ import type {
OverlayContentMeasurement, OverlayContentMeasurement,
ShortcutsConfig, ShortcutsConfig,
ConfigHotReloadPayload, ConfigHotReloadPayload,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
} from './types'; } from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts'; import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> => getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> => getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { notifyOverlayModalClosed: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
}, },
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => { notifyOverlayModalOpened: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
}, },
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {

View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
let nextTimerId = 1;
const scheduled = new Map<number, () => void>();
const classList = createClassList(['hidden']);
const toast = {
textContent: '',
classList,
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
durationMs: 1500,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
scheduled.set(id, callback);
return id as never;
},
clearTimeout: (id) => {
scheduled.delete(id as never as number);
},
},
);
indicator.update({
connectedGamepads: [],
activeGamepadId: null,
});
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(classList.contains('hidden'), false);
assert.match(toast.textContent, /controller detected/i);
assert.match(toast.textContent, /pad-1/i);
assert.equal(scheduled.size, 1);
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(scheduled.size, 1);
const [hide] = scheduled.values();
hide?.();
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
});
test('controller status indicator announces newly detected controllers after startup', () => {
const toast = {
textContent: '',
classList: createClassList(['hidden']),
};
const indicator = createControllerStatusIndicator(
{ controllerStatusToast: toast } as never,
{
setTimeout: () => 1 as never,
clearTimeout: () => {},
},
);
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
toast.classList.add('hidden');
toast.textContent = '';
indicator.update({
connectedGamepads: [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
],
activeGamepadId: 'pad-1',
});
assert.equal(toast.classList.contains('hidden'), false);
assert.match(toast.textContent, /pad-2/i);
});

View File

@@ -0,0 +1,69 @@
import type { ControllerDeviceInfo } from '../types';
type ControllerSnapshot = {
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
};
type ControllerStatusIndicatorOptions = {
durationMs?: number;
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
};
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
if (!device) return 'Controller';
return device.id || `Gamepad ${device.index}`;
}
export function createControllerStatusIndicator(
dom: {
controllerStatusToast: {
textContent: string;
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
};
},
options: ControllerStatusIndicatorOptions = {},
) {
const durationMs = options.durationMs ?? 2200;
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
const cancelTimeout =
options.clearTimeout ??
((timer: ReturnType<typeof setTimeout> | number) =>
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
let previousConnectedIds = new Set<string>();
function show(message: string): void {
if (hideTimeout !== null) {
cancelTimeout(hideTimeout);
hideTimeout = null;
}
dom.controllerStatusToast.textContent = message;
dom.controllerStatusToast.classList.remove('hidden');
hideTimeout = scheduleTimeout(() => {
dom.controllerStatusToast.classList.add('hidden');
dom.controllerStatusToast.textContent = '';
hideTimeout = null;
}, durationMs);
}
function update(snapshot: ControllerSnapshot): void {
const newDevices = snapshot.connectedGamepads.filter(
(device) => !previousConnectedIds.has(device.id),
);
if (newDevices.length > 0) {
const activeDevice = snapshot.connectedGamepads.find(
(device) => device.id === snapshot.activeGamepadId,
);
const announcedDevice =
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
}
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
}
return { update };
}

View File

@@ -0,0 +1,645 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ResolvedControllerConfig } from '../../types';
import { createGamepadController } from './gamepad-controller.js';
type TestGamepad = {
id: string;
index: number;
connected: boolean;
mapping: string;
axes: number[];
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
};
function createGamepad(
id: string,
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
): TestGamepad {
return {
id,
index: options.index ?? 0,
connected: true,
mapping: 'standard',
axes: options.axes ?? [0, 0, 0, 0],
buttons:
options.buttons ??
Array.from({ length: 16 }, () => ({
value: 0,
pressed: false,
touched: false,
})),
};
}
function createControllerConfig(
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
bindings?: Partial<ResolvedControllerConfig['bindings']>;
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
} = {},
): ResolvedControllerConfig {
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
overrides;
return {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
...(buttonIndexOverrides ?? {}),
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
...(bindingOverrides ?? {}),
},
...restOverrides,
};
}
test('gamepad controller selects the first connected controller by default', () => {
const updates: string[] = [];
const controller = createGamepadController({
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: (state) => {
updates.push(state.activeGamepadId ?? 'none');
},
});
controller.poll(0);
assert.equal(controller.getActiveGamepadId(), 'pad-2');
assert.deepEqual(updates.at(-1), 'pad-2');
});
test('gamepad controller prefers saved controller id when connected', () => {
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.equal(controller.getActiveGamepadId(), 'pad-2');
});
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig({ enabled: false }),
getKeyboardModeEnabled: () => false,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, []);
});
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
const calls: string[] = [];
const selectionCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
let axes = [0.9, 0, 0, 0];
let keyboardModeEnabled = true;
let interactionBlocked = true;
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => keyboardModeEnabled,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => interactionBlocked,
toggleKeyboardMode: () => {},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
interactionBlocked = false;
controller.poll(100);
assert.deepEqual(calls, []);
assert.deepEqual(selectionCalls, []);
buttons[0] = { value: 0, pressed: false, touched: false };
axes = [0, 0, 0, 0];
controller.poll(200);
buttons[0] = { value: 1, pressed: true, touched: true };
axes = [0.9, 0, 0, 0];
controller.poll(300);
assert.deepEqual(calls, ['toggle-lookup']);
assert.deepEqual(selectionCalls, [1]);
});
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
const calls: number[] = [];
let axes = [0.9, 0, 0, 0];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => calls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
controller.poll(260);
assert.deepEqual(calls, [1]);
controller.poll(340);
assert.deepEqual(calls, [1, 1]);
axes = [0, 0, 0, 0];
controller.poll(360);
axes = [-0.9, 0, 0, 0];
controller.poll(380);
assert.deepEqual(calls, [1, 1, -1]);
});
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
const calls: string[] = [];
const scrollCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[8] = { value: 1, pressed: true, touched: true };
buttons[4] = { value: 1, pressed: true, touched: true };
buttons[5] = { value: 1, pressed: true, touched: true };
buttons[6] = { value: 0.8, pressed: true, touched: true };
buttons[7] = { value: 0.9, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () =>
[
createGamepad('pad-1', {
axes: [0, -0.75, 0.1, 0, 0.8],
buttons,
}),
],
getConfig: () =>
createControllerConfig({
bindings: {
playCurrentAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
previousAudio: 'none',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => calls.push('prev-audio'),
nextAudio: () => calls.push('next-audio'),
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: (delta) => calls.push(`jump:${delta}`),
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.equal(calls.includes('next-audio'), true);
assert.equal(calls.includes('play-audio'), true);
assert.equal(calls.includes('prev-audio'), false);
assert.equal(calls.includes('toggle-mpv-pause'), true);
assert.equal(calls.includes('quit-mpv'), true);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
assert.equal(calls.includes('jump:160'), true);
});
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['quit-mpv']);
});
test('gamepad controller honors configured raw button index overrides', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[11] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
buttonIndices: {
select: 11,
},
bindings: { quitMpv: 'select' },
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => calls.push('quit-mpv'),
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['quit-mpv']);
});
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
const calls: string[] = [];
let axes = [0, 0, 0.85, 0, 0];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: (delta) => calls.push(`jump:${delta}`),
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(calls, []);
axes = [0, 0, 0.85, 0, -0.85];
controller.poll(200);
assert.deepEqual(calls, ['jump:-160']);
});
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
const selectionCalls: number[] = [];
const scrollCalls: number[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[15] = { value: 1, pressed: false, touched: true };
buttons[12] = { value: 1, pressed: false, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
});
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
const selectionCalls: number[] = [];
const scrollCalls: number[] = [];
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: (delta) => selectionCalls.push(delta),
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: (delta) => scrollCalls.push(delta),
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
controller.poll(100);
assert.deepEqual(selectionCalls, [1]);
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
});
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 0.7, pressed: false, touched: true };
buttons[7] = { value: 0.8, pressed: false, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
triggerInputMode: 'analog',
triggerDeadzone: 0.6,
bindings: {
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
});
test('gamepad controller trigger digital mode uses pressed state only', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 0.9, pressed: true, touched: true };
buttons[7] = { value: 0.9, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
triggerInputMode: 'digital',
triggerDeadzone: 1,
bindings: {
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
});
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[9] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
bindings: {
toggleMpvPause: 'leftStickPress',
playCurrentAudio: 'none',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-mpv-pause']);
});

View File

@@ -0,0 +1,571 @@
import type {
ControllerAxisBinding,
ControllerButtonBinding,
ControllerDeviceInfo,
ControllerRuntimeSnapshot,
ControllerTriggerInputMode,
ResolvedControllerConfig,
} from '../../types';
type ControllerButtonState = {
value: number;
pressed?: boolean;
touched?: boolean;
};
type GamepadLike = {
id: string;
index: number;
connected: boolean;
mapping: string;
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
type GamepadControllerOptions = {
getGamepads: () => Array<GamepadLike | null>;
getConfig: () => ResolvedControllerConfig;
getKeyboardModeEnabled: () => boolean;
getLookupWindowOpen: () => boolean;
getInteractionBlocked: () => boolean;
toggleKeyboardMode: () => void;
toggleLookup: () => void;
closeLookup: () => void;
moveSelection: (delta: -1 | 1) => void;
mineCard: () => void;
quitMpv: () => void;
previousAudio: () => void;
nextAudio: () => void;
playCurrentAudio: () => void;
toggleMpvPause: () => void;
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void;
};
type HoldState = {
repeatStarted: boolean;
direction: -1 | 1 | null;
lastFireAt: number;
initialFired: boolean;
};
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
select: 8,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
};
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const DPAD_BUTTON_INDEX = {
up: 12,
down: 13,
left: 14,
right: 15,
} as const;
const DPAD_AXIS_INDEX = {
horizontal: 6,
vertical: 7,
} as const;
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
return binding === 'leftTrigger' || binding === 'rightTrigger';
}
function resolveButtonIndex(
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
): number {
if (binding === 'none') {
return -1;
}
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
}
function normalizeButtonState(
gamepad: GamepadLike,
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
triggerInputMode: ControllerTriggerInputMode,
triggerDeadzone: number,
): boolean {
if (binding === 'none') {
return false;
}
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
if (isTriggerBinding(binding)) {
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
}
return normalizeRawButtonState(button, triggerDeadzone);
}
function normalizeRawButtonState(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
): boolean {
if (!button) return false;
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function normalizeTriggerState(
button: ControllerButtonState | undefined,
mode: ControllerTriggerInputMode,
triggerDeadzone: number,
): boolean {
if (!button) return false;
if (mode === 'digital') {
return Boolean(button.pressed);
}
if (mode === 'analog') {
return button.value >= triggerDeadzone;
}
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
}
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
const value = gamepad.axes[axisIndex];
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
function resolveDpadValue(
gamepad: GamepadLike,
negativeIndex: number,
positiveIndex: number,
triggerDeadzone: number,
): number {
const negative = gamepad.buttons[negativeIndex];
const positive = gamepad.buttons[positiveIndex];
return (
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
);
}
function resolveDpadAxisValue(
gamepad: GamepadLike,
axisIndex: number,
): number {
const value = resolveGamepadAxis(gamepad, axisIndex);
if (Math.abs(value) < 0.5) {
return 0;
}
return Math.sign(value);
}
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
if (axisValue !== 0) {
return axisValue;
}
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
}
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
if (axisValue !== 0) {
return axisValue;
}
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
}
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
return gamepads
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
.sort((left, right) => left.index - right.index);
}
function createHoldState(): HoldState {
return {
repeatStarted: false,
direction: null,
lastFireAt: 0,
initialFired: false,
};
}
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
if (!state.initialFired) {
state.initialFired = true;
state.lastFireAt = now;
return true;
}
const elapsed = now - state.lastFireAt;
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
if (elapsed < threshold) {
return false;
}
state.repeatStarted = true;
state.lastFireAt = now;
return true;
}
function resetHeldAction(state: HoldState): void {
state.repeatStarted = false;
state.direction = null;
state.lastFireAt = 0;
state.initialFired = false;
}
function syncHeldActionBlocked(
state: HoldState,
value: number,
now: number,
activationThreshold: number,
): void {
if (Math.abs(value) < activationThreshold) {
resetHeldAction(state);
return;
}
const direction = value > 0 ? 1 : -1;
state.repeatStarted = false;
state.direction = direction;
state.lastFireAt = now;
state.initialFired = true;
}
export function createGamepadController(options: GamepadControllerOptions) {
let previousButtons = new Map<ControllerButtonBinding, boolean>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
let lastPollAt: number | null = null;
function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads());
}
function resolveActiveGamepad(
gamepads: GamepadLike[],
config: ResolvedControllerConfig,
): GamepadLike | null {
if (gamepads.length === 0) return null;
if (config.preferredGamepadId.trim().length > 0) {
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
if (preferred) {
return preferred;
}
}
return gamepads[0] ?? null;
}
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
activeGamepadId = activeGamepad?.id ?? null;
options.onState({
connectedGamepads: gamepads.map((gamepad) => ({
id: gamepad.id,
index: gamepad.index,
mapping: gamepad.mapping,
connected: gamepad.connected,
})) satisfies ControllerDeviceInfo[],
activeGamepadId,
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
rawButtons: activeGamepad?.buttons
? activeGamepad.buttons.map((button) => ({
value: button.value,
pressed: Boolean(button.pressed),
touched: button.touched,
}))
: [],
});
}
function handleButtonEdge(
binding: ControllerButtonBinding,
isPressed: boolean,
action: () => void,
): void {
if (binding === 'none') {
return;
}
const wasPressed = previousButtons.get(binding) ?? false;
previousButtons.set(binding, isPressed);
if (!wasPressed && isPressed) {
action();
}
}
function handleSelectionAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(selectionHold);
return;
}
const direction = value > 0 ? 1 : -1;
if (selectionHold.direction !== direction) {
resetHeldAction(selectionHold);
selectionHold.direction = direction;
}
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
options.moveSelection(direction);
}
}
function handleJumpAxis(
value: number,
now: number,
config: ResolvedControllerConfig,
): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
resetHeldAction(jumpHold);
return;
}
const direction = value > 0 ? 1 : -1;
if (jumpHold.direction !== direction) {
resetHeldAction(jumpHold);
jumpHold.direction = direction;
}
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
options.jumpPopup(direction * config.horizontalJumpPixels);
}
}
function syncBlockedInteractionState(
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
now: number,
): void {
const buttonBindings = new Set<ControllerButtonBinding>([
config.bindings.toggleKeyboardOnlyMode,
config.bindings.toggleLookup,
config.bindings.closeLookup,
config.bindings.mineCard,
config.bindings.quitMpv,
config.bindings.previousAudio,
config.bindings.nextAudio,
config.bindings.playCurrentAudio,
config.bindings.toggleMpvPause,
]);
for (const binding of buttonBindings) {
if (binding === 'none') continue;
previousButtons.set(
binding,
normalizeButtonState(
activeGamepad,
config,
binding,
config.triggerInputMode,
config.triggerDeadzone,
),
);
}
const selectionValue = (() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})();
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
if (options.getLookupWindowOpen()) {
syncHeldActionBlocked(
jumpHold,
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
now,
Math.max(config.stickDeadzone, 0.55),
);
} else {
resetHeldAction(jumpHold);
}
}
function poll(now: number): void {
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
lastPollAt = now;
const config = options.getConfig();
const connectedGamepads = getConnectedGamepads();
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) {
previousButtons = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
lastPollAt = null;
return;
}
const interactionAllowed =
config.enabled &&
options.getKeyboardModeEnabled() &&
!options.getInteractionBlocked();
if (config.enabled) {
handleButtonEdge(
config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleKeyboardMode,
);
}
if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, config, now);
return;
}
handleButtonEdge(
config.bindings.toggleLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleLookup,
);
handleButtonEdge(
config.bindings.closeLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.closeLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
options.closeLookup,
);
handleButtonEdge(
config.bindings.mineCard,
normalizeButtonState(
activeGamepad,
config,
config.bindings.mineCard,
config.triggerInputMode,
config.triggerDeadzone,
),
options.mineCard,
);
handleButtonEdge(
config.bindings.quitMpv,
normalizeButtonState(
activeGamepad,
config,
config.bindings.quitMpv,
config.triggerInputMode,
config.triggerDeadzone,
),
options.quitMpv,
);
if (options.getLookupWindowOpen()) {
handleButtonEdge(
config.bindings.previousAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.previousAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.previousAudio,
);
handleButtonEdge(
config.bindings.nextAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.nextAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.nextAudio,
);
handleButtonEdge(
config.bindings.playCurrentAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.playCurrentAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
options.playCurrentAudio,
);
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
if (elapsedMs > 0) {
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
if (dpadVertical !== 0) {
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
}
handleJumpAxis(
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
now,
config,
);
} else {
resetHeldAction(jumpHold);
}
handleButtonEdge(
config.bindings.toggleMpvPause,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleMpvPause,
config.triggerInputMode,
config.triggerDeadzone,
),
options.toggleMpvPause,
);
handleSelectionAxis(
(() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})(),
now,
config,
);
}
return {
poll,
getActiveGamepadId: (): string | null => activeGamepadId,
};
}

View File

@@ -3,7 +3,10 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js'; import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; import {
YOMITAN_POPUP_COMMAND_EVENT,
YOMITAN_POPUP_HIDDEN_EVENT,
} from '../yomitan-popup.js';
type CommandEventDetail = { type CommandEventDetail = {
type?: string; type?: string;
@@ -11,6 +14,9 @@ type CommandEventDetail = {
key?: string; key?: string;
code?: string; code?: string;
repeat?: boolean; repeat?: boolean;
direction?: number;
deltaX?: number;
deltaY?: number;
}; };
function createClassList() { function createClassList() {
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent; const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const documentListeners = new Map<string, Array<(event: unknown) => void>>(); const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = []; const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
let playbackPausedResponse: boolean | null = false; let playbackPausedResponse: boolean | null = false;
let selectionClearCount = 0;
let selectionAddCount = 0;
let popupVisible = false; let popupVisible = false;
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
}; };
const selection = { const selection = {
removeAllRanges: () => {}, removeAllRanges: () => {
addRange: () => {}, selectionClearCount += 1;
},
addRange: () => {
selectionAddCount += 1;
},
}; };
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = []; const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: { value: {
addEventListener: () => {}, addEventListener: (type: string, listener: (event: unknown) => void) => {
const listeners = windowListeners.get(type) ?? [];
listeners.push(listener);
windowListeners.set(type, listeners);
},
dispatchEvent: (event: Event) => { dispatchEvent: (event: Event) => {
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) { if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
const detail = (event as Event & { detail?: CommandEventDetail }).detail; const detail = (event as Event & { detail?: CommandEventDetail }).detail;
commandEvents.push(detail ?? {}); commandEvents.push(detail ?? {});
} }
const listeners = windowListeners.get(event.type) ?? [];
for (const listener of listeners) {
listener(event);
}
return true; return true;
}, },
getComputedStyle: () => ({ getComputedStyle: () => ({
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
} }
} }
function dispatchWindowEvent(type: string): void {
const listeners = windowListeners.get(type) ?? [];
for (const listener of listeners) {
listener(new Event(type));
}
}
function restore() { function restore() {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
windowFocusCalls: () => windowFocusCalls, windowFocusCalls: () => windowFocusCalls,
dispatchKeydown, dispatchKeydown,
dispatchFocusInOnPopup, dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => { setPopupVisible: (value: boolean) => {
popupVisible = value; popupVisible = value;
}, },
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
setPlaybackPausedResponse: (value: boolean | null) => { setPlaybackPausedResponse: (value: boolean | null) => {
playbackPausedResponse = value; playbackPausedResponse = value;
}, },
selectionClearCount: () => selectionClearCount,
selectionAddCount: () => selectionAddCount,
restore, restore,
}; };
} }
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() { function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals(); const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList(); const subtitleRootClassList = createClassList();
let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0;
const createWordNode = (left: number) => ({ const createWordNode = (left: number) => ({
classList: createClassList(), classList: createClassList(),
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false, handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false, handleKikuKeydown: () => false,
handleJimakuKeydown: () => false, handleJimakuKeydown: () => false,
handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1;
return true;
},
handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false, handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {}, appendClipboardVideoToQueue: () => {},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(), getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectOpenCount += 1;
},
openControllerDebugModal: () => {
controllerDebugOpenCount += 1;
},
}); });
return { return {
ctx, ctx,
handlers, handlers,
testGlobals, testGlobals,
controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
setWordCount: (count: number) => { setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
}, },
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
} }
}); });
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
assert.equal(handlers.playCurrentAudioForController(), true);
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
assert.equal(handlers.scrollPopupByController(48, -24), true);
assert.deepEqual(
testGlobals.commandEvents.slice(-3),
[
{ type: 'playCurrentAudio' },
{ type: 'cycleAudioSource', direction: 1 },
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
],
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.controllerSelectModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(controllerSelectKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => { test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
} }
}); });
test('keyboard mode: turning mode off clears selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
handlers.handleLookupWindowToggleRequested();
await wait(0);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
assert.equal(testGlobals.selectionAddCount() > 0, true);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.closeLookupWindow();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
assert.equal(testGlobals.selectionClearCount() > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
handlers.handleLookupWindowToggleRequested();
await wait(0);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => { test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
} }
}); });
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
assert.equal(handlers.moveSelectionForController(1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
assert.equal(handlers.moveSelectionForController(-1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => { test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
} }
}); });
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(3);
ctx.state.keyboardSelectedWordIndex = 2;
handlers.syncKeyboardTokenSelection();
handlers.handleSubtitleContentUpdated();
setWordCount(4);
handlers.syncKeyboardTokenSelection();
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => { test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();

View File

@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: { openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK'; bindingKey: 'KeyH' | 'KeyK';
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
}) => void; }) => void;
appendClipboardVideoToQueue: () => void; appendClipboardVideoToQueue: () => void;
getPlaybackPaused: () => Promise<boolean | null>; getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void;
openControllerDebugModal: () => void;
}, },
) { ) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K). // Timeout for the modal chord capture window (e.g. Y followed by H/K).
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false; let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false;
const CHORD_MAP = new Map< const CHORD_MAP = new Map<
string, string,
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
); );
} }
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'cycleAudioSource',
direction,
},
}),
);
}
function dispatchYomitanPopupPlayCurrentAudio() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'playCurrentAudio',
},
}),
);
}
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'scrollBy',
deltaX,
deltaY,
},
}),
);
}
function dispatchYomitanFrontendScanSelectedText() { function dispatchYomitanFrontendScanSelectedText() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
); );
} }
function dispatchYomitanFrontendClearActiveTextSource() {
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'clearActiveTextSource',
},
}),
);
}
function isPrimaryModifierPressed(e: KeyboardEvent): boolean { function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey; return e.ctrlKey || e.metaKey;
} }
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
} }
function isControllerModalShortcut(e: KeyboardEvent): boolean {
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function getSubtitleWordNodes(): HTMLElement[] { function getSubtitleWordNodes(): HTMLElement[] {
return Array.from( return Array.from(
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'), ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
); );
} }
function syncKeyboardTokenSelection(): void { function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
const wordNodes = getSubtitleWordNodes();
for (const wordNode of wordNodes) { for (const wordNode of wordNodes) {
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS); wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
} }
}
function clearNativeSubtitleSelection(): void {
window.getSelection()?.removeAllRanges();
ctx.dom.subtitleRoot.classList.remove('has-selection');
}
function syncKeyboardTokenSelection(): void {
const wordNodes = getSubtitleWordNodes();
clearKeyboardSelectedWordClasses(wordNodes);
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) { if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
ctx.state.keyboardSelectedWordIndex = null; ctx.state.keyboardSelectedWordIndex = null;
ctx.state.keyboardSelectionVisible = false;
if (!ctx.state.keyboardDrivenModeEnabled) { if (!ctx.state.keyboardDrivenModeEnabled) {
pendingSelectionAnchorAfterSubtitleSeek = null; pendingSelectionAnchorAfterSubtitleSeek = null;
pendingLookupRefreshAfterSubtitleSeek = false; pendingLookupRefreshAfterSubtitleSeek = false;
resetSelectionToStartOnNextSubtitleSync = false;
clearNativeSubtitleSelection();
} }
return; return;
} }
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
if (pendingSelectionAnchorAfterSubtitleSeek) { if (pendingSelectionAnchorAfterSubtitleSeek) {
ctx.state.keyboardSelectedWordIndex = ctx.state.keyboardSelectedWordIndex =
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1; pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
ctx.state.keyboardSelectionVisible = true;
pendingSelectionAnchorAfterSubtitleSeek = null; pendingSelectionAnchorAfterSubtitleSeek = null;
resetSelectionToStartOnNextSubtitleSync = false;
const shouldRefreshLookup = const shouldRefreshLookup =
pendingLookupRefreshAfterSubtitleSeek && pendingLookupRefreshAfterSubtitleSeek &&
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)); (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
} }
} }
if (resetSelectionToStartOnNextSubtitleSync) {
ctx.state.keyboardSelectedWordIndex = 0;
ctx.state.keyboardSelectionVisible = true;
resetSelectionToStartOnNextSubtitleSync = false;
}
const selectedIndex = Math.min( const selectedIndex = Math.min(
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
wordNodes.length - 1, wordNodes.length - 1,
); );
ctx.state.keyboardSelectedWordIndex = selectedIndex; ctx.state.keyboardSelectedWordIndex = selectedIndex;
const selectedWordNode = wordNodes[selectedIndex]; const selectedWordNode = wordNodes[selectedIndex];
if (selectedWordNode) { if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS); selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
} }
} }
function setKeyboardDrivenModeEnabled(enabled: boolean): void { function setKeyboardDrivenModeEnabled(enabled: boolean): void {
ctx.state.keyboardDrivenModeEnabled = enabled; ctx.state.keyboardDrivenModeEnabled = enabled;
ctx.state.keyboardSelectionVisible = enabled;
if (!enabled) { if (!enabled) {
ctx.state.keyboardSelectedWordIndex = null; ctx.state.keyboardSelectedWordIndex = null;
pendingSelectionAnchorAfterSubtitleSeek = null; pendingSelectionAnchorAfterSubtitleSeek = null;
pendingLookupRefreshAfterSubtitleSeek = false; pendingLookupRefreshAfterSubtitleSeek = false;
resetSelectionToStartOnNextSubtitleSync = false;
clearNativeSubtitleSelection();
} }
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
} }
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
const nextIndex = currentIndex + delta; const nextIndex = currentIndex + delta;
ctx.state.keyboardSelectedWordIndex = nextIndex; ctx.state.keyboardSelectedWordIndex = nextIndex;
ctx.state.keyboardSelectionVisible = true;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
return 'moved'; return 'moved';
} }
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
const selectedWordNode = wordNodes[selectedIndex]; const selectedWordNode = wordNodes[selectedIndex];
if (!selectedWordNode) return false; if (!selectedWordNode) return false;
ctx.state.keyboardSelectionVisible = true;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
selectWordNodeText(selectedWordNode); selectWordNodeText(selectedWordNode);
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
toggleKeyboardDrivenMode(); toggleKeyboardDrivenMode();
} }
function handleSubtitleContentUpdated(): void {
if (!ctx.state.keyboardDrivenModeEnabled) {
return;
}
if (pendingSelectionAnchorAfterSubtitleSeek) {
return;
}
resetSelectionToStartOnNextSubtitleSync = true;
}
function handleLookupWindowToggleRequested(): void { function handleLookupWindowToggleRequested(): void {
if (ctx.state.yomitanPopupVisible) { if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
closeLookupWindow();
return;
}
triggerLookupForSelectedWord();
}
function closeLookupWindow(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupVisibility(false); dispatchYomitanPopupVisibility(false);
dispatchYomitanFrontendClearActiveTextSource();
clearNativeSubtitleSelection();
if (ctx.state.keyboardDrivenModeEnabled) { if (ctx.state.keyboardDrivenModeEnabled) {
queueMicrotask(() => { queueMicrotask(() => {
restoreOverlayKeyboardFocus(); restoreOverlayKeyboardFocus();
}); });
} }
return; return true;
} }
function moveSelectionForController(delta: -1 | 1): boolean {
if (!ctx.state.keyboardDrivenModeEnabled) {
return false;
}
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
const result = moveKeyboardSelection(delta);
if (result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
return true;
}
if (result === 'start-boundary' || result === 'end-boundary') {
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
} else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord(); triggerLookupForSelectedWord();
} }
return true;
}
function forwardPopupKeydownForController(
key: string,
code: string,
repeat: boolean = true,
): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupKeydown(key, code, [], repeat);
return true;
}
function mineSelectedFromController(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupMineSelected();
return true;
}
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupCycleAudioSource(direction);
return true;
}
function playCurrentAudioForController(): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupPlayCurrentAudio();
return true;
}
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
return false;
}
dispatchYomitanPopupScrollBy(deltaX, deltaY);
return true;
}
function restoreOverlayKeyboardFocus(): void { function restoreOverlayKeyboardFocus(): void {
void window.electronAPI.focusMainWindow(); void window.electronAPI.focusMainWindow();
window.focus(); window.focus();
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
const key = e.code; const key = e.code;
if (key === 'ArrowLeft') { if (key === 'ArrowLeft') {
const result = moveKeyboardSelection(-1); const result = moveKeyboardSelection(-1);
if (result === 'start-boundary') { if (result === 'start-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(-1, false); seekAdjacentSubtitleAndQueueSelection(-1, false);
} }
return result !== 'no-words'; return true;
} }
if (key === 'ArrowRight' || key === 'KeyL') { if (key === 'ArrowRight' || key === 'KeyL') {
const result = moveKeyboardSelection(1); const result = moveKeyboardSelection(1);
if (result === 'end-boundary') { if (result === 'end-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(1, false); seekAdjacentSubtitleAndQueueSelection(1, false);
} }
return result !== 'no-words'; return true;
} }
return false; return false;
} }
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
if (key === 'ArrowLeft' || key === 'KeyH') { if (key === 'ArrowLeft' || key === 'KeyH') {
const result = moveKeyboardSelection(-1); const result = moveKeyboardSelection(-1);
if (result === 'start-boundary') { if (result === 'start-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible); seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
} else if (popupVisible && result === 'moved') { } else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord(); triggerLookupForSelectedWord();
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
if (key === 'ArrowRight' || key === 'KeyL') { if (key === 'ArrowRight' || key === 'KeyL') {
const result = moveKeyboardSelection(1); const result = moveKeyboardSelection(1);
if (result === 'end-boundary') { if (result === 'end-boundary' || result === 'no-words') {
seekAdjacentSubtitleAndQueueSelection(1, popupVisible); seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
} else if (popupVisible && result === 'moved') { } else if (popupVisible && result === 'moved') {
triggerLookupForSelectedWord(); triggerLookupForSelectedWord();
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
}); });
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
clearNativeSubtitleSelection();
if (!ctx.state.keyboardDrivenModeEnabled) { if (!ctx.state.keyboardDrivenModeEnabled) {
syncKeyboardTokenSelection();
return; return;
} }
restoreOverlayKeyboardFocus(); restoreOverlayKeyboardFocus();
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
return; return;
} }
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.runtimeOptionsModalOpen) { if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e); options.handleRuntimeOptionsKeydown(e);
return; return;
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e); options.handleJimakuKeydown(e);
return; return;
} }
if (ctx.state.controllerSelectModalOpen) {
options.handleControllerSelectKeydown(e);
return;
}
if (ctx.state.controllerDebugModalOpen) {
options.handleControllerDebugKeydown(e);
return;
}
if (ctx.state.sessionHelpModalOpen) { if (ctx.state.sessionHelpModalOpen) {
options.handleSessionHelpKeydown(e); options.handleSessionHelpKeydown(e);
return; return;
} }
if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)
) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
}
return;
}
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) { if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
e.preventDefault(); e.preventDefault();
return; return;
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
return; return;
} }
if (isControllerModalShortcut(e)) {
e.preventDefault();
if (e.shiftKey) {
options.openControllerDebugModal();
} else {
options.openControllerSelectModal();
}
return;
}
const keyString = keyEventToString(e); const keyString = keyEventToString(e);
const command = ctx.state.keybindingsMap.get(keyString); const command = ctx.state.keybindingsMap.get(keyString);
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
setupMpvInputForwarding, setupMpvInputForwarding,
updateKeybindings, updateKeybindings,
syncKeyboardTokenSelection, syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
handleKeyboardModeToggleRequested, handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested, handleLookupWindowToggleRequested,
closeLookupWindow,
moveSelectionForController,
forwardPopupKeydownForController,
mineSelectedFromController,
cyclePopupAudioSourceForController,
playCurrentAudioForController,
scrollPopupByController,
}; };
} }

View File

@@ -30,6 +30,12 @@
<body> <body>
<!-- Programmatic focus fallback target for Electron/window focus management. --> <!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1"> <div id="overlay" tabindex="-1">
<div
id="controllerStatusToast"
class="controller-status-toast hidden"
role="status"
aria-live="polite"
></div>
<div <div
id="overlayErrorToast" id="overlayErrorToast"
class="overlay-error-toast hidden" class="overlay-error-toast hidden"
@@ -192,6 +198,62 @@
</div> </div>
</div> </div>
</div> </div>
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content">
<div class="modal-header">
<div class="modal-title">Controller Selection</div>
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="controllerSelectHint" class="runtime-options-hint">
Arrow keys: select controller · Enter: save · Esc: close
</div>
<ul id="controllerSelectList" class="runtime-options-list"></ul>
<div id="controllerSelectStatus" class="runtime-options-status"></div>
<div class="subsync-footer">
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
Save Controller
</button>
</div>
</div>
</div>
</div>
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content controller-debug-content">
<div
id="controllerDebugToast"
class="controller-debug-toast hidden"
aria-live="polite"
></div>
<div class="modal-header">
<div class="modal-title">Controller Debug</div>
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="controllerDebugStatus" class="runtime-options-status"></div>
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
<div class="controller-debug-grid">
<div>
<div class="jimaku-section-title">Axes</div>
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
</div>
<div>
<div class="jimaku-section-title">Buttons</div>
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
</div>
<div class="controller-debug-span">
<div class="controller-debug-section-header">
<div class="jimaku-section-title">Config</div>
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
Copy
</button>
</div>
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
</div>
</div>
</div>
</div>
</div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true"> <div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content"> <div class="modal-content session-help-content">
<div class="modal-header"> <div class="modal-header">

View File

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerDebugModal } from './controller-debug.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
const globals = globalThis as typeof globalThis & { window?: unknown };
const previousWindow = globals.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalClosed: () => {},
},
},
});
try {
const state = createRendererState();
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
state.controllerRawAxes = [0.5, -0.25];
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
state.controllerConfig = {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
controllerDebugModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerDebugClose: { addEventListener: () => {} },
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
controllerDebugStatus: { textContent: '', classList: createClassList() },
controllerDebugSummary: { textContent: '' },
controllerDebugAxes: { textContent: '' },
controllerDebugButtons: { textContent: '' },
controllerDebugButtonIndices: { textContent: '' },
},
state,
};
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerDebugModal();
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('controller debug modal copies buttonIndices config to clipboard', async () => {
const globals = globalThis as typeof globalThis & {
window?: unknown;
navigator?: unknown;
};
const previousWindow = globals.window;
const previousNavigator = globals.navigator;
const copied: string[] = [];
const handlers: { copy: null | (() => void) } = { copy: null };
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
clipboard: {
writeText: async (text: string) => {
copied.push(text);
},
},
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
controllerDebugModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerDebugClose: { addEventListener: () => {} },
controllerDebugCopy: {
addEventListener: (_event: string, handler: () => void) => {
handlers.copy = handler;
},
},
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
controllerDebugStatus: { textContent: '', classList: createClassList() },
controllerDebugSummary: { textContent: '' },
controllerDebugAxes: { textContent: '' },
controllerDebugButtons: { textContent: '' },
controllerDebugButtonIndices: { textContent: '' },
},
state,
};
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerDebugModal();
if (handlers.copy) {
handlers.copy();
}
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});

View File

@@ -0,0 +1,192 @@
import type { ModalStateReader, RendererContext } from '../context';
function formatAxes(values: number[]): string {
if (values.length === 0) return 'No controller axes available.';
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
}
function formatButtons(
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
): string {
if (values.length === 0) return 'No controller buttons available.';
return values
.map(
(button, index) =>
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
)
.join('\n');
}
function formatButtonIndices(
value:
| {
select: number;
buttonSouth: number;
buttonEast: number;
buttonNorth: number;
buttonWest: number;
leftShoulder: number;
rightShoulder: number;
leftStickPress: number;
rightStickPress: number;
leftTrigger: number;
rightTrigger: number;
}
| null,
): string {
if (!value) {
return 'No controller config loaded.';
}
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
}
async function writeTextToClipboard(text: string): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API unavailable.');
}
await navigator.clipboard.writeText(text);
}
export function createControllerDebugModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function setStatus(message: string, isError: boolean = false): void {
ctx.dom.controllerDebugStatus.textContent = message;
if (isError) {
ctx.dom.controllerDebugStatus.classList.add('error');
} else {
ctx.dom.controllerDebugStatus.classList.remove('error');
}
}
function clearToastTimer(): void {
if (toastTimer === null) return;
clearTimeout(toastTimer);
toastTimer = null;
}
function hideToast(): void {
clearToastTimer();
ctx.dom.controllerDebugToast.classList.add('hidden');
ctx.dom.controllerDebugToast.classList.remove('error');
}
function showToast(message: string, isError: boolean = false): void {
clearToastTimer();
ctx.dom.controllerDebugToast.textContent = message;
ctx.dom.controllerDebugToast.classList.remove('hidden');
if (isError) {
ctx.dom.controllerDebugToast.classList.add('error');
} else {
ctx.dom.controllerDebugToast.classList.remove('error');
}
toastTimer = setTimeout(() => {
hideToast();
}, 1800);
}
function render(): void {
const activeDevice = ctx.state.connectedGamepads.find(
(device) => device.id === ctx.state.activeGamepadId,
);
setStatus(
activeDevice?.id ??
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
);
ctx.dom.controllerDebugSummary.textContent =
ctx.state.connectedGamepads.length > 0
? ctx.state.connectedGamepads
.map((device) => {
const tags = [
`#${device.index}`,
device.mapping,
device.id === ctx.state.activeGamepadId ? 'active' : null,
].filter(Boolean);
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
})
.join('\n')
: 'Connect a controller and press any button to populate raw input values.';
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
ctx.state.controllerConfig?.buttonIndices ?? null,
);
}
async function copyButtonIndicesToClipboard(): Promise<void> {
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
if (text.length === 0 || text === 'No controller config loaded.') {
setStatus('No buttonIndices config available to copy.', true);
showToast('No buttonIndices config available to copy.', true);
return;
}
try {
await writeTextToClipboard(text);
setStatus('Copied controller buttonIndices config.');
showToast('Copied controller buttonIndices config.');
} catch {
setStatus('Failed to copy controller buttonIndices config.', true);
showToast('Failed to copy controller buttonIndices config.', true);
}
}
function openControllerDebugModal(): void {
ctx.state.controllerDebugModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.controllerDebugModal.classList.remove('hidden');
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
hideToast();
render();
}
function closeControllerDebugModal(): void {
if (!ctx.state.controllerDebugModalOpen) return;
ctx.state.controllerDebugModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerDebugModal.classList.add('hidden');
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
hideToast();
window.electronAPI.notifyOverlayModalClosed('controller-debug');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
closeControllerDebugModal();
return true;
}
return true;
}
function updateSnapshot(): void {
if (!ctx.state.controllerDebugModalOpen) return;
render();
}
function wireDomEvents(): void {
ctx.dom.controllerDebugClose.addEventListener('click', () => {
closeControllerDebugModal();
});
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
void copyButtonIndicesToClipboard();
});
}
return {
openControllerDebugModal,
closeControllerDebugModal,
handleControllerDebugKeydown,
updateSnapshot,
wireDomEvents,
};
}

View File

@@ -0,0 +1,727 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerSelectModal } from './controller-select.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return tokens.has(entry);
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
test('controller select modal saves the selected preferred controller', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async (update: {
preferredGamepadId: string;
preferredGamepadLabel: string;
}) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const overlayClassList = createClassList();
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-2',
preferredGamepadLabel: 'pad-2',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: overlayClassList, focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.deepEqual(saved, [
{
preferredGamepadId: 'pad-2',
preferredGamepadLabel: 'pad-2',
},
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves manual selection while controller polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 0);
modal.handleControllerSelectKeydown({
key: 'ArrowDown',
preventDefault: () => {},
} as KeyboardEvent);
assert.equal(state.controllerDeviceSelectedIndex, 1);
modal.updateDevices();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal prefers active controller over saved preferred controller', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves saved status across polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
modal.updateDevices();
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal surfaces save errors without mutating saved preference', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {
throw new Error('disk write failed');
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let appendCount = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {
appendCount += 1;
},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
const initialAppendCount = appendCount;
modal.updateDevices();
assert.equal(appendCount, initialAppendCount);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -0,0 +1,264 @@
import type { ModalStateReader, RendererContext } from '../context';
function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) {
ctx.state.controllerDeviceSelectedIndex = 0;
return;
}
ctx.state.controllerDeviceSelectedIndex = Math.min(
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
ctx.state.connectedGamepads.length - 1,
);
}
export function createControllerSelectModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let selectedControllerId: string | null = null;
let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
function getDevicesKey(): string {
return ctx.state.connectedGamepads
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
.join('||');
}
function syncSelectedControllerId(): void {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
selectedControllerId = selected?.id ?? null;
}
function syncSelectedIndexToCurrentController(): void {
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const activeIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === ctx.state.activeGamepadId,
);
if (activeIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = activeIndex;
syncSelectedControllerId();
return;
}
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
if (preferredIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
syncSelectedControllerId();
return;
}
clampSelectedIndex(ctx);
syncSelectedControllerId();
}
function setStatus(message: string, isError = false): void {
ctx.dom.controllerSelectStatus.textContent = message;
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
}
function renderList(): void {
ctx.dom.controllerSelectList.innerHTML = '';
clampSelectedIndex(ctx);
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
ctx.state.connectedGamepads.forEach((device, index) => {
const li = document.createElement('li');
li.className = 'runtime-options-list-entry';
const button = document.createElement('button');
button.type = 'button';
button.className = 'runtime-options-item runtime-options-item-button';
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
const label = document.createElement('div');
label.className = 'runtime-options-label';
label.textContent = device.id || `Gamepad ${device.index}`;
const meta = document.createElement('div');
meta.className = 'runtime-options-value';
const tags = [
`Index ${device.index}`,
device.mapping || 'unknown mapping',
device.id === ctx.state.activeGamepadId ? 'active' : null,
device.id === preferredId ? 'saved' : null,
].filter(Boolean);
meta.textContent = tags.join(' · ');
button.appendChild(label);
button.appendChild(meta);
button.addEventListener('click', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
renderList();
});
button.addEventListener('dblclick', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
void saveSelectedController();
});
li.appendChild(button);
ctx.dom.controllerSelectList.appendChild(li);
});
lastRenderedDevicesKey = getDevicesKey();
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
lastRenderedPreferredId = preferredId;
}
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerId) {
const preservedIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === selectedControllerId,
);
if (preservedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
} else {
syncSelectedIndexToCurrentController();
}
} else {
syncSelectedIndexToCurrentController();
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderList();
}
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
return;
}
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
if (
currentStatus !== 'No controller selected.' &&
!currentStatus.startsWith('Saved preferred controller:')
) {
setStatus('Select a controller to save as preferred.');
}
}
async function saveSelectedController(): Promise<void> {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
if (!selected) {
setStatus('No controller selected.', true);
return;
}
try {
await window.electronAPI.saveControllerPreference({
preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save preferred controller: ${message}`, true);
return;
}
if (ctx.state.controllerConfig) {
ctx.state.controllerConfig.preferredGamepadId = selected.id;
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
}
syncSelectedControllerId();
renderList();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
}
function openControllerSelectModal(): void {
ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController();
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.controllerSelectModal.classList.remove('hidden');
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
window.focus();
ctx.dom.overlay.focus({ preventScroll: true });
renderList();
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
} else {
setStatus('Select a controller to save as preferred.');
}
}
function closeControllerSelectModal(): void {
if (!ctx.state.controllerSelectModalOpen) return;
ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('controller-select');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
closeControllerSelectModal();
return true;
}
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
event.preventDefault();
if (ctx.state.connectedGamepads.length > 0) {
ctx.state.controllerDeviceSelectedIndex = Math.min(
ctx.state.connectedGamepads.length - 1,
ctx.state.controllerDeviceSelectedIndex + 1,
);
syncSelectedControllerId();
renderList();
}
return true;
}
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
event.preventDefault();
if (ctx.state.connectedGamepads.length > 0) {
ctx.state.controllerDeviceSelectedIndex = Math.max(
0,
ctx.state.controllerDeviceSelectedIndex - 1,
);
syncSelectedControllerId();
renderList();
}
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
void saveSelectedController();
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.controllerSelectClose.addEventListener('click', () => {
closeControllerSelectModal();
});
ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController();
});
}
return {
openControllerSelectModal,
closeControllerSelectModal,
handleControllerSelectKeydown,
updateDevices,
wireDomEvents,
};
}

View File

@@ -26,7 +26,11 @@ import type {
ConfigHotReloadPayload, ConfigHotReloadPayload,
} from '../types'; } from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js'; import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js';
import { createMouseHandlers } from './handlers/mouse.js'; import { createMouseHandlers } from './handlers/mouse.js';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js'; import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js'; import { createSessionHelpModal } from './modals/session-help.js';
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { createRendererState } from './state.js'; import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js'; import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible } from './yomitan-popup.js';
import { import {
createRendererRecoveryController, createRendererRecoveryController,
registerRendererGlobalErrorHandlers, registerRendererGlobalErrorHandlers,
@@ -55,6 +60,8 @@ const ctx = {
function isAnySettingsModalOpen(): boolean { function isAnySettingsModalOpen(): boolean {
return ( return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen || ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
function isAnyModalOpen(): boolean { function isAnyModalOpen(): boolean {
return ( return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen || ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const controllerSelectModal = createControllerSelectModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerDebugModal = createControllerDebugModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, { const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal, openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
appendClipboardVideoToQueue: () => { appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue(); void window.electronAPI.appendClipboardVideoToQueue();
}, },
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
}); });
const mouseHandlers = createMouseHandlers(ctx, { const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
let lastSubtitlePreview = ''; let lastSubtitlePreview = '';
let lastSecondarySubtitlePreview = ''; let lastSecondarySubtitlePreview = '';
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null; let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
let controllerAnimationFrameId: number | null = null;
function truncateForErrorLog(text: string): string { function truncateForErrorLog(text: string): string {
const normalized = text.replace(/\s+/g, ' ').trim(); const normalized = text.replace(/\s+/g, ' ').trim();
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
} }
function getActiveModal(): string | null { function getActiveModal(): string | null {
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
} }
function dismissActiveUiAfterError(): void { function dismissActiveUiAfterError(): void {
if (ctx.state.controllerSelectModalOpen) {
controllerSelectModal.closeControllerSelectModal();
}
if (ctx.state.controllerDebugModalOpen) {
controllerDebugModal.closeControllerDebugModal();
}
if (ctx.state.jimakuModalOpen) { if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal(); jimakuModal.closeJimakuModal();
} }
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
syncSettingsModalSubtitleSuppression(); syncSettingsModalSubtitleSuppression();
} }
function applyControllerSnapshot(snapshot: {
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
activeGamepadId: string | null;
rawAxes: number[];
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
}): void {
controllerStatusIndicator.update({
connectedGamepads: snapshot.connectedGamepads,
activeGamepadId: snapshot.activeGamepadId,
});
ctx.state.connectedGamepads = snapshot.connectedGamepads;
ctx.state.activeGamepadId = snapshot.activeGamepadId;
ctx.state.controllerRawAxes = snapshot.rawAxes;
ctx.state.controllerRawButtons = snapshot.rawButtons;
controllerSelectModal.updateDevices();
controllerDebugModal.updateSnapshot();
}
function emitControllerPopupScroll(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels);
}
function emitControllerPopupJump(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
}
function startControllerPolling(): void {
if (controllerAnimationFrameId !== null) {
cancelAnimationFrame(controllerAnimationFrameId);
controllerAnimationFrameId = null;
}
const gamepadController = createGamepadController({
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
getConfig: () =>
ctx.state.controllerConfig ?? {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
getInteractionBlocked: () => isAnyModalOpen(),
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
closeLookup: () => {
keyboardHandlers.closeLookupWindow();
},
moveSelection: (delta) => {
keyboardHandlers.moveSelectionForController(delta);
},
mineCard: () => {
keyboardHandlers.mineSelectedFromController();
},
quitMpv: () => {
window.electronAPI.sendMpvCommand(['quit']);
},
previousAudio: () => {
keyboardHandlers.cyclePopupAudioSourceForController(-1);
},
nextAudio: () => {
keyboardHandlers.cyclePopupAudioSourceForController(1);
},
playCurrentAudio: () => {
keyboardHandlers.playCurrentAudioForController();
},
toggleMpvPause: () => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
},
scrollPopup: (deltaPixels) => {
emitControllerPopupScroll(deltaPixels);
},
jumpPopup: (deltaPixels) => {
emitControllerPopupJump(deltaPixels);
},
onState: (snapshot) => {
applyControllerSnapshot(snapshot);
},
});
const poll = (now: number): void => {
gamepadController.poll(now);
controllerAnimationFrameId = requestAnimationFrame(poll);
};
controllerAnimationFrameId = requestAnimationFrame(poll);
}
function restoreOverlayInteractionAfterError(): void { function restoreOverlayInteractionAfterError(): void {
ctx.state.isOverSubtitle = false; ctx.state.isOverSubtitle = false;
ctx.dom.overlay.classList.remove('interactive'); ctx.dom.overlay.classList.remove('interactive');
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => { window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => { runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data)); lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data); subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule(); measurementReporter.schedule();
}); });
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw(); initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
} }
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle)); lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule(); measurementReporter.schedule();
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();
controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents(); sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
mouseHandlers.setupDragging(); mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding(); await keyboardHandlers.setupMpvInputForwarding();
try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
} catch (error) {
console.error('Failed to load controller config.', error);
ctx.state.controllerConfig = null;
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);

View File

@@ -1,4 +1,7 @@
import type { import type {
ControllerButtonSnapshot,
ControllerDeviceInfo,
ResolvedControllerConfig,
JimakuEntry, JimakuEntry,
JimakuFileEntry, JimakuFileEntry,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
@@ -53,6 +56,15 @@ export type RendererState = {
subsyncSourceTracks: SubsyncSourceTrack[]; subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubmitting: boolean; subsyncSubmitting: boolean;
controllerSelectModalOpen: boolean;
controllerDebugModalOpen: boolean;
controllerDeviceSelectedIndex: number;
controllerConfig: ResolvedControllerConfig | null;
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
controllerRawAxes: number[];
controllerRawButtons: ControllerButtonSnapshot[];
sessionHelpModalOpen: boolean; sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number; sessionHelpSelectedIndex: number;
@@ -82,6 +94,7 @@ export type RendererState = {
chordPending: boolean; chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null; chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean; keyboardDrivenModeEnabled: boolean;
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null; keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean; yomitanPopupVisible: boolean;
}; };
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
subsyncSourceTracks: [], subsyncSourceTracks: [],
subsyncSubmitting: false, subsyncSubmitting: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
controllerDeviceSelectedIndex: 0,
controllerConfig: null,
connectedGamepads: [],
activeGamepadId: null,
controllerRawAxes: [],
controllerRawButtons: [],
sessionHelpModalOpen: false, sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0, sessionHelpSelectedIndex: 0,
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
chordPending: false, chordPending: false,
chordTimeout: null, chordTimeout: null,
keyboardDrivenModeEnabled: false, keyboardDrivenModeEnabled: false,
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null, keyboardSelectedWordIndex: null,
yomitanPopupVisible: false, yomitanPopupVisible: false,
}; };

View File

@@ -55,6 +55,34 @@ body {
pointer-events: auto; pointer-events: auto;
} }
.controller-status-toast {
position: absolute;
top: 16px;
left: 16px;
max-width: min(360px, calc(100vw - 32px));
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(138, 213, 202, 0.45);
background:
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
color: rgba(228, 255, 251, 0.98);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 160ms ease,
transform 160ms ease;
z-index: 1300;
}
.controller-status-toast:not(.hidden) {
opacity: 1;
transform: translateY(0);
}
.overlay-error-toast { .overlay-error-toast {
position: absolute; position: absolute;
top: 16px; top: 16px;
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
pointer-events: none !important; pointer-events: none !important;
} }
body.settings-modal-open iframe.yomitan-popup,
body.settings-modal-open iframe[id^='yomitan-popup'] {
display: none !important;
pointer-events: none !important;
}
#subtitleRoot .c { #subtitleRoot .c {
display: inline; display: inline;
position: relative; position: relative;
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
overflow-y: auto; overflow-y: auto;
} }
.runtime-options-list-entry {
list-style: none;
}
.runtime-options-item { .runtime-options-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
cursor: pointer; cursor: pointer;
} }
.runtime-options-item:last-child { .runtime-options-item-button {
width: 100%;
border: none;
background: transparent;
text-align: left;
color: inherit;
}
.runtime-options-list-entry:last-child .runtime-options-item {
border-bottom: none; border-bottom: none;
} }
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
background: rgba(100, 180, 255, 0.15); background: rgba(100, 180, 255, 0.15);
} }
.runtime-options-item-button:focus-visible {
outline: 2px solid rgba(100, 180, 255, 0.85);
outline-offset: -2px;
}
.runtime-options-label { .runtime-options-label {
font-size: 14px; font-size: 14px;
color: #fff; color: #fff;
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
color: #ff8f8f; color: #ff8f8f;
} }
.controller-debug-content {
position: relative;
width: min(760px, 94%);
}
.controller-debug-toast {
position: absolute;
top: 18px;
right: 56px;
z-index: 2;
max-width: min(320px, calc(100% - 88px));
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 214, 168, 0.34);
background: rgba(20, 38, 30, 0.96);
color: rgba(220, 255, 232, 0.98);
font-size: 12px;
line-height: 1.3;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
.controller-debug-toast.error {
border-color: rgba(255, 143, 143, 0.34);
background: rgba(52, 22, 24, 0.96);
color: rgba(255, 225, 225, 0.98);
}
.controller-debug-summary {
min-height: 18px;
font-size: 13px;
color: rgba(255, 255, 255, 0.86);
line-height: 1.45;
}
.controller-debug-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.controller-debug-span {
grid-column: 1 / -1;
}
.controller-debug-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.controller-debug-pre {
min-height: 220px;
margin: 0;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.38);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 1.5;
overflow: auto;
white-space: pre-wrap;
}
.session-help-content { .session-help-content {
width: min(760px, 92%); width: min(760px, 92%);
max-height: 84%; max-height: 84%;
color: rgba(255, 255, 255, 0.95); color: rgba(255, 255, 255, 0.95);
} }
@media (max-width: 720px) {
.controller-debug-grid {
grid-template-columns: 1fr;
}
}
.session-help-shortcut, .session-help-shortcut,
.session-help-warning, .session-help-warning,
.session-help-status { .session-help-status {

View File

@@ -2,6 +2,7 @@ export type RendererDom = {
subtitleRoot: HTMLElement; subtitleRoot: HTMLElement;
subtitleContainer: HTMLElement; subtitleContainer: HTMLElement;
overlay: HTMLElement; overlay: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement; overlayErrorToast: HTMLDivElement;
secondarySubContainer: HTMLElement; secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement; secondarySubRoot: HTMLElement;
@@ -56,6 +57,23 @@ export type RendererDom = {
subsyncRunButton: HTMLButtonElement; subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement; subsyncStatus: HTMLDivElement;
controllerSelectModal: HTMLDivElement;
controllerSelectClose: HTMLButtonElement;
controllerSelectHint: HTMLDivElement;
controllerSelectStatus: HTMLDivElement;
controllerSelectList: HTMLUListElement;
controllerSelectSave: HTMLButtonElement;
controllerDebugModal: HTMLDivElement;
controllerDebugClose: HTMLButtonElement;
controllerDebugCopy: HTMLButtonElement;
controllerDebugToast: HTMLDivElement;
controllerDebugStatus: HTMLDivElement;
controllerDebugSummary: HTMLDivElement;
controllerDebugAxes: HTMLPreElement;
controllerDebugButtons: HTMLPreElement;
controllerDebugButtonIndices: HTMLPreElement;
sessionHelpModal: HTMLDivElement; sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement; sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement; sessionHelpShortcut: HTMLDivElement;
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'), subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'), subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'), overlay: getRequiredElement<HTMLElement>('overlay'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'), overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'), secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'), secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'), subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'), subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'), sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'), sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'), sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
let scrolledTo: { x: number; y: number } | null = null;
const result = Display.prototype._onMessageScrollBy.call(
{
_windowScroll: {
x: 24,
y: 80,
to(x: number, y: number) {
scrolledTo = { x, y };
},
},
},
{ deltaX: 12, deltaY: -20 },
);
assert.equal(result, true);
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
});

View File

@@ -1,6 +1,13 @@
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const; export const OVERLAY_HOSTED_MODALS = [
'runtime-options',
'subsync',
'jimaku',
'kiku',
'controller-select',
'controller-debug',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
toggleDevTools: 'toggle-dev-tools', toggleDevTools: 'toggle-dev-tools',
toggleOverlay: 'toggle-overlay', toggleOverlay: 'toggle-overlay',
saveSubtitlePosition: 'save-subtitle-position', saveSubtitlePosition: 'save-subtitle-position',
saveControllerPreference: 'save-controller-preference',
setMecabEnabled: 'set-mecab-enabled', setMecabEnabled: 'set-mecab-enabled',
mpvCommand: 'mpv-command', mpvCommand: 'mpv-command',
setAnkiConnectEnabled: 'set-anki-connect-enabled', setAnkiConnectEnabled: 'set-anki-connect-enabled',
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
getMecabStatus: 'get-mecab-status', getMecabStatus: 'get-mecab-status',
getKeybindings: 'get-keybindings', getKeybindings: 'get-keybindings',
getConfigShortcuts: 'get-config-shortcuts', getConfigShortcuts: 'get-config-shortcuts',
getControllerConfig: 'get-controller-config',
getSecondarySubMode: 'get-secondary-sub-mode', getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub', getCurrentSecondarySub: 'get-current-secondary-sub',
focusMainWindow: 'focus-main-window', focusMainWindow: 'focus-main-window',

View File

@@ -1,4 +1,5 @@
import type { import type {
ControllerPreferenceUpdate,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuFilesQuery, JimakuFilesQuery,
JimakuSearchQuery, JimakuSearchQuery,
@@ -48,6 +49,16 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
}; };
} }
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
if (!isObject(value)) return null;
if (typeof value.preferredGamepadId !== 'string') return null;
if (typeof value.preferredGamepadLabel !== 'string') return null;
return {
preferredGamepadId: value.preferredGamepadId,
preferredGamepadLabel: value.preferredGamepadLabel,
};
}
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null { export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null; if (!isObject(value)) return null;
const { engine, sourceTrackId } = value; const { engine, sourceTrackId } = value;

View File

@@ -375,6 +375,94 @@ export interface ShortcutsConfig {
openJimaku?: string | null; openJimaku?: string | null;
} }
export type ControllerButtonBinding =
| 'none'
| 'select'
| 'buttonSouth'
| 'buttonEast'
| 'buttonNorth'
| 'buttonWest'
| 'leftShoulder'
| 'rightShoulder'
| 'leftStickPress'
| 'rightStickPress'
| 'leftTrigger'
| 'rightTrigger';
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
export interface ControllerBindingsConfig {
toggleLookup?: ControllerButtonBinding;
closeLookup?: ControllerButtonBinding;
toggleKeyboardOnlyMode?: ControllerButtonBinding;
mineCard?: ControllerButtonBinding;
quitMpv?: ControllerButtonBinding;
previousAudio?: ControllerButtonBinding;
nextAudio?: ControllerButtonBinding;
playCurrentAudio?: ControllerButtonBinding;
toggleMpvPause?: ControllerButtonBinding;
leftStickHorizontal?: ControllerAxisBinding;
leftStickVertical?: ControllerAxisBinding;
rightStickHorizontal?: ControllerAxisBinding;
rightStickVertical?: ControllerAxisBinding;
}
export interface ControllerButtonIndicesConfig {
select?: number;
buttonSouth?: number;
buttonEast?: number;
buttonNorth?: number;
buttonWest?: number;
leftShoulder?: number;
rightShoulder?: number;
leftStickPress?: number;
rightStickPress?: number;
leftTrigger?: number;
rightTrigger?: number;
}
export interface ControllerConfig {
enabled?: boolean;
preferredGamepadId?: string;
preferredGamepadLabel?: string;
smoothScroll?: boolean;
scrollPixelsPerSecond?: number;
horizontalJumpPixels?: number;
stickDeadzone?: number;
triggerInputMode?: ControllerTriggerInputMode;
triggerDeadzone?: number;
repeatDelayMs?: number;
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
}
export interface ControllerPreferenceUpdate {
preferredGamepadId: string;
preferredGamepadLabel: string;
}
export interface ControllerDeviceInfo {
id: string;
index: number;
mapping: string;
connected: boolean;
}
export interface ControllerButtonSnapshot {
value: number;
pressed: boolean;
touched?: boolean;
}
export interface ControllerRuntimeSnapshot {
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
rawAxes: number[];
rawButtons: ControllerButtonSnapshot[];
}
export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface JimakuConfig { export interface JimakuConfig {
@@ -487,6 +575,7 @@ export interface Config {
websocket?: WebSocketConfig; websocket?: WebSocketConfig;
annotationWebsocket?: AnnotationWebSocketConfig; annotationWebsocket?: AnnotationWebSocketConfig;
texthooker?: TexthookerConfig; texthooker?: TexthookerConfig;
controller?: ControllerConfig;
ankiConnect?: AnkiConnectConfig; ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig; shortcuts?: ShortcutsConfig;
secondarySub?: SecondarySubConfig; secondarySub?: SecondarySubConfig;
@@ -514,6 +603,21 @@ export interface ResolvedConfig {
websocket: Required<WebSocketConfig>; websocket: Required<WebSocketConfig>;
annotationWebsocket: Required<AnnotationWebSocketConfig>; annotationWebsocket: Required<AnnotationWebSocketConfig>;
texthooker: Required<TexthookerConfig>; texthooker: Required<TexthookerConfig>;
controller: {
enabled: boolean;
preferredGamepadId: string;
preferredGamepadLabel: string;
smoothScroll: boolean;
scrollPixelsPerSecond: number;
horizontalJumpPixels: number;
stickDeadzone: number;
triggerInputMode: ControllerTriggerInputMode;
triggerDeadzone: number;
repeatDelayMs: number;
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ControllerBindingsConfig>;
};
ankiConnect: AnkiConnectConfig & { ankiConnect: AnkiConnectConfig & {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -838,6 +942,8 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
} }
export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface SubtitleHoverTokenPayload { export interface SubtitleHoverTokenPayload {
tokenIndex: number | null; tokenIndex: number | null;
} }
@@ -862,6 +968,8 @@ export interface ElectronAPI {
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>; getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>; getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>; getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>; jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>; jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
@@ -895,8 +1003,24 @@ export interface ElectronAPI {
onKeyboardModeToggleRequested: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; notifyOverlayModalClosed: (
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void; modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
) => void;
notifyOverlayModalOpened: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'kiku'
| 'controller-select'
| 'controller-debug',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
} }