mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
6d0f37ac21
commit
b99409bbcd
60 files changed
+1900
-219
No files matched your search
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The controller settings window (`Alt+C`) no longer edits a controller it cannot see. A newly opened window cannot see any controller until you press one of its buttons (browsers hide a pad from a new window until then); it used to show and edit the global defaults meanwhile, and its status could stay stuck on "No controllers detected". Now it shows the bindings of the controller the overlay is using (or the saved one), labelled as a preview, and Learn, Clear, Reset and the button style are locked until the press.
|
||||
- With several controllers connected, `Alt+C` now selects the one you just pressed, and Learn listens to that controller.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Controller remaps made in `Alt+C` now apply right away; before, the overlay kept the old bindings until SubMiner restarted.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` now warns when two controller actions share an input, as the dialog layouts already did. Both rows show "⚠ Same as …", including a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` has a **Button Style** choice (Xbox, Nintendo or PlayStation), saved per controller. PlayStation is new: buttons and the review and duplicate card shortcuts show ╳ ○ □ △. `controller.faceButtonLabels` accepts `playstation` and stays the default for controllers without their own style.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Clicking a row in `Alt+C` shows its **Learn**, **Clear** and **Reset** buttons again. The panel opened but was squeezed to nothing once the list was taller than the window, so **Clear** (set a binding to None) could not be reached.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- The controller's **Toggle MPV Pause** button now works without keyboard-only mode, so you can pause while just watching.
|
||||
@@ -2,4 +2,3 @@ type: added
|
||||
area: overlay
|
||||
|
||||
- The media timing review controller layout can now be changed in the new **Review media timing** group of `Alt+C`. Learn records a single button or a combo (hold one button, press another), any button can be a hold button, changes are saved per controller, rows warn about clashing inputs, and **Reset all** restores the default layout. The review's button hints follow your bindings.
|
||||
- A newly opened `Alt+C` window shows and edits your saved controller's bindings until it sees the controller (browsers hide a pad from a new window until a button is pressed in it); before, it showed and edited the global defaults, and the status could stay stuck on "No controllers detected".
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New controller actions for subtitle lines, in the **Subtitles** group of `Alt+C`: **Previous Subtitle**, **Next Subtitle**, **Replay Subtitle** and **Play Next Subtitle**, matching `Shift+H`, `Shift+L`, `Ctrl+Shift+H` and `Ctrl+Shift+L`. They are unbound by default and work with or without keyboard-only mode.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New **Pause Video In Keyboard-Only Mode** setting (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default): turning keyboard-only mode on, from the keyboard or a controller, pauses the video, and turning it off resumes it. A video that was already paused, or that you played or paused yourself in the meantime, is left as it is.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Turning keyboard-only mode off, from the keyboard or a controller, now also closes the dictionary popup.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Moving between words with the keyboard or a controller right after closing the dictionary popup no longer reopens it. A popup that closed under the mouse pointer was still treated as open.
|
||||
+15
-2
@@ -108,7 +108,7 @@
|
||||
"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.
|
||||
"faceButtonLabels": "xbox", // Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C. Values: xbox | nintendo
|
||||
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
@@ -159,7 +159,7 @@
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"holdSecondarySub": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
@@ -169,6 +169,18 @@
|
||||
"nextEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"replaySubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"playNextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -762,6 +774,7 @@
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
|
||||
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
||||
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||
|
||||
@@ -164,7 +164,7 @@ When you cancel, you can go back to editing, keep the original timing, create th
|
||||
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||
|
||||
`A`, `B`, `X` and `Y` mean the letters printed on your controller. If yours has A on the right and B at the bottom (Switch-style and many 8BitDo pads), set `controller.faceButtonLabels` to `"nintendo"`; PlayStation pads use the default (`A` is ✕, `B` ○, `X` □, `Y` △).
|
||||
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
||||
|
||||
Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held.
|
||||
|
||||
@@ -267,7 +267,7 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
|
||||
|
||||
### With a controller {#field-grouping-with-a-controller}
|
||||
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (see `controller.faceButtonLabels`), and a button already held when the dialog opens waits for a fresh press.
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||
|
||||
| Controller | Choosing the card | Merge preview |
|
||||
| ------------------ | --------------------------------- | ----------------------- |
|
||||
|
||||
+38
-37
@@ -175,30 +175,31 @@ Controls how primary and secondary subtitles look and which annotations they sho
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
|
||||
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
|
||||
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
|
||||
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
|
||||
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
|
||||
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
|
||||
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
|
||||
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
|
||||
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
|
||||
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
|
||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
|
||||
| Key | Default | What it does |
|
||||
| ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
|
||||
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
|
||||
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
|
||||
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
|
||||
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
|
||||
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
|
||||
| `subtitleStyle.autoPauseVideoOnKeyboardMode` | `false` | Pause while keyboard-only mode is on (keyboard or controller toggle). Leaving resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, stays as it is |
|
||||
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
|
||||
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
|
||||
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
|
||||
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
|
||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
|
||||
|
||||
Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
|
||||
|
||||
@@ -290,20 +291,20 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
||||
|
||||
### Controller support
|
||||
|
||||
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode and the secondary subtitle hold (`controller.bindings.holdSecondarySub`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
|
||||
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
|
||||
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
|
||||
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
|
||||
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
|
||||
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
|
||||
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
|
||||
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
|
||||
| `controller.faceButtonLabels` | `"xbox"` | Letters printed on your face buttons. Use `nintendo` if A is on the right and B at the bottom (Switch-style and many 8BitDo pads); PlayStation pads use `xbox` |
|
||||
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
|
||||
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
|
||||
| Key | Default | What it does |
|
||||
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
|
||||
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
|
||||
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
|
||||
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
|
||||
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
|
||||
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
|
||||
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
|
||||
| `controller.faceButtonLabels` | `"xbox"` | Face button style: `xbox`, `nintendo` (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or `playstation` (╳ ○ □ △). **Button Style** in `Alt+C` sets it per controller; this is the default for the others |
|
||||
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
|
||||
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
|
||||
|
||||
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"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.
|
||||
"faceButtonLabels": "xbox", // Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C. Values: xbox | nintendo
|
||||
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
@@ -159,7 +159,7 @@
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"holdSecondarySub": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
@@ -169,6 +169,18 @@
|
||||
"nextEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"replaySubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"playNextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -762,6 +774,7 @@
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
|
||||
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
||||
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||
|
||||
+11
-2
@@ -74,6 +74,10 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
|
||||
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
|
||||
**Pause Video In Keyboard-Only Mode** (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default) pauses mpv when keyboard-only mode turns on, from the keyboard or a controller, and resumes it when the mode turns off. It only resumes a pause it made: if the video was already paused, or you played or paused it yourself in the meantime, leaving the mode leaves playback as it is.
|
||||
|
||||
Turning keyboard-only mode off also closes the dictionary popup, whether or not this setting is on.
|
||||
|
||||
You can drop files onto the overlay:
|
||||
|
||||
- A video replaces what is playing. Hold `Shift` to add it to the playlist instead.
|
||||
@@ -160,7 +164,7 @@ You can drive the overlay with a gamepad.
|
||||
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
|
||||
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
|
||||
|
||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
|
||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
|
||||
|
||||
| Button | Action |
|
||||
| --------------------- | -------------------------------------- |
|
||||
@@ -170,16 +174,21 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
||||
| `Y` (North) | Toggle keyboard-only mode |
|
||||
| `L1` | Play the current Yomitan audio |
|
||||
| `R1` | Next Yomitan audio source |
|
||||
| `L3` | Pause or resume mpv |
|
||||
| `L3` | Pause or resume mpv (any mode) |
|
||||
| `Select` / `Minus` | Quit mpv |
|
||||
| Left stick | Move between words, scroll the popup |
|
||||
| Right stick (up/down) | Jump through the popup |
|
||||
| Unbound by default | Show the secondary subtitle while held |
|
||||
| Unbound by default | Previous word entry in the popup |
|
||||
| Unbound by default | Next word entry in the popup |
|
||||
| Unbound by default | Previous / next subtitle line |
|
||||
| Unbound by default | Replay the current line, then pause |
|
||||
| Unbound by default | Play the next line, then pause |
|
||||
|
||||
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
|
||||
|
||||
The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previous Subtitle** and **Next Subtitle** jump a line like `Shift+H` / `Shift+L` (a paused video stays paused), **Replay Subtitle** is `Ctrl+Shift+H` and **Play Next Subtitle** is `Ctrl+Shift+L`. Like the secondary subtitle hold and pause, they work with or without keyboard-only mode. If you put them on the D-pad, click **Clear** on the matching **(D-pad)** row under **Navigation** too (both rows warn until you do), or in keyboard-only mode the D-pad will also move between words or scroll the popup.
|
||||
|
||||
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
|
||||
|
||||
The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller).
|
||||
|
||||
@@ -2216,6 +2216,34 @@ test('parses controller face button labels and warns on unknown styles', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('controller profiles inherit the button style unless they set their own', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
controller: {
|
||||
faceButtonLabels: 'nintendo',
|
||||
profiles: {
|
||||
'pad-ps': { faceButtonLabels: 'playstation' },
|
||||
'pad-plain': { label: 'Plain' },
|
||||
'pad-bad': { faceButtonLabels: 'sega' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const service = new ConfigService(dir);
|
||||
const { profiles } = service.getConfig().controller;
|
||||
|
||||
assert.equal(profiles['pad-ps']!.faceButtonLabels, 'playstation');
|
||||
assert.equal(profiles['pad-plain']!.faceButtonLabels, 'nintendo');
|
||||
assert.equal(profiles['pad-bad']!.faceButtonLabels, 'nintendo');
|
||||
assert.deepEqual(
|
||||
service.getWarnings().map((warning) => warning.path),
|
||||
['controller.profiles.pad-bad.faceButtonLabels'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parses controller review bindings globally and per profile, and warns on bad entries', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -85,6 +85,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
autoPauseVideoOnKeyboardMode: false,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
|
||||
@@ -61,7 +61,8 @@ export function buildCoreConfigOptionRegistry(
|
||||
{
|
||||
id: 'toggleMpvPause',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
||||
description: 'Controller binding descriptor for toggling mpv play/pause.',
|
||||
description:
|
||||
'Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
id: 'holdSecondarySub',
|
||||
@@ -81,6 +82,30 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
|
||||
},
|
||||
{
|
||||
id: 'previousSubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
|
||||
description:
|
||||
'Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
id: 'nextSubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.nextSubtitle,
|
||||
description:
|
||||
'Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
id: 'replaySubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.replaySubtitle,
|
||||
description:
|
||||
'Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode.',
|
||||
},
|
||||
{
|
||||
id: 'playNextSubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.playNextSubtitle,
|
||||
description:
|
||||
'Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const axisBindings = [
|
||||
@@ -235,10 +260,10 @@ export function buildCoreConfigOptionRegistry(
|
||||
{
|
||||
path: 'controller.faceButtonLabels',
|
||||
kind: 'enum',
|
||||
enumValues: ['xbox', 'nintendo'],
|
||||
enumValues: ['xbox', 'nintendo', 'playstation'],
|
||||
defaultValue: defaultConfig.controller.faceButtonLabels,
|
||||
description:
|
||||
"Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C.",
|
||||
'Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others.',
|
||||
},
|
||||
{
|
||||
path: 'controller.triggerDeadzone',
|
||||
|
||||
@@ -100,6 +100,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.autoPauseVideoOnKeyboardMode',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnKeyboardMode,
|
||||
description:
|
||||
'Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types/runtime';
|
||||
import {
|
||||
isControllerFaceButtonLabels,
|
||||
isControllerScreenAction,
|
||||
parseControllerReviewBinding,
|
||||
type ControllerScreenBindings,
|
||||
@@ -18,6 +19,8 @@ import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'.";
|
||||
|
||||
const CONTROLLER_BUTTON_BINDINGS = [
|
||||
'none',
|
||||
'select',
|
||||
@@ -98,6 +101,10 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||
@@ -360,15 +367,15 @@ export function applyControllerConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const faceButtonLabels = asString(src.controller.faceButtonLabels);
|
||||
if (faceButtonLabels === 'xbox' || faceButtonLabels === 'nintendo') {
|
||||
const faceButtonLabels = src.controller.faceButtonLabels;
|
||||
if (isControllerFaceButtonLabels(faceButtonLabels)) {
|
||||
resolved.controller.faceButtonLabels = faceButtonLabels;
|
||||
} else if (src.controller.faceButtonLabels !== undefined) {
|
||||
} else if (faceButtonLabels !== undefined) {
|
||||
warn(
|
||||
'controller.faceButtonLabels',
|
||||
src.controller.faceButtonLabels,
|
||||
faceButtonLabels,
|
||||
resolved.controller.faceButtonLabels,
|
||||
"Expected 'xbox' or 'nintendo'.",
|
||||
FACE_BUTTON_LABELS_EXPECTED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -465,9 +472,21 @@ export function applyControllerConfig(context: ResolveContext): void {
|
||||
label: label ?? profileId,
|
||||
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
||||
bindings: structuredClone(resolved.controller.bindings),
|
||||
faceButtonLabels: resolved.controller.faceButtonLabels,
|
||||
reviewBindings: structuredClone(resolved.controller.reviewBindings),
|
||||
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
|
||||
};
|
||||
// Set per controller from Alt+C; falls back to controller.faceButtonLabels.
|
||||
if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) {
|
||||
profile.faceButtonLabels = rawProfile.faceButtonLabels;
|
||||
} else if (rawProfile.faceButtonLabels !== undefined) {
|
||||
warn(
|
||||
`controller.profiles.${profileId}.faceButtonLabels`,
|
||||
rawProfile.faceButtonLabels,
|
||||
profile.faceButtonLabels,
|
||||
FACE_BUTTON_LABELS_EXPECTED,
|
||||
);
|
||||
}
|
||||
applyControllerButtonIndices(
|
||||
rawProfile.buttonIndices,
|
||||
profile.buttonIndices,
|
||||
|
||||
@@ -125,6 +125,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode =
|
||||
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode;
|
||||
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
@@ -274,6 +276,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const rawAutoPauseVideoOnKeyboardMode = (
|
||||
src.subtitleStyle as { autoPauseVideoOnKeyboardMode?: unknown }
|
||||
).autoPauseVideoOnKeyboardMode;
|
||||
const autoPauseVideoOnKeyboardMode = asBoolean(rawAutoPauseVideoOnKeyboardMode);
|
||||
if (autoPauseVideoOnKeyboardMode !== undefined) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode = autoPauseVideoOnKeyboardMode;
|
||||
} else if (rawAutoPauseVideoOnKeyboardMode !== undefined) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode =
|
||||
fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode;
|
||||
warn(
|
||||
'subtitleStyle.autoPauseVideoOnKeyboardMode',
|
||||
rawAutoPauseVideoOnKeyboardMode,
|
||||
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const primaryVisibleOnYomitanPopup = asBoolean(
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup,
|
||||
|
||||
@@ -346,3 +346,28 @@ test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnKeyboardMode is off by default and falls back on invalid value', () => {
|
||||
const defaults = createResolveContext({});
|
||||
applySubtitleDomainConfig(defaults.context);
|
||||
assert.equal(defaults.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
|
||||
|
||||
const enabled = createResolveContext({ subtitleStyle: { autoPauseVideoOnKeyboardMode: true } });
|
||||
applySubtitleDomainConfig(enabled.context);
|
||||
assert.equal(enabled.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, true);
|
||||
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
autoPauseVideoOnKeyboardMode: 'yes' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(context);
|
||||
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -233,6 +233,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||
'subtitleStyle.autoPauseVideoOnKeyboardMode': 'Pause Video In Keyboard-Only Mode',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
@@ -344,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' ||
|
||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
|
||||
@@ -76,6 +76,10 @@ function createControllerConfigFixture() {
|
||||
holdSecondarySub: { kind: 'none' as const },
|
||||
previousEntry: { kind: 'none' as const },
|
||||
nextEntry: { kind: 'none' as const },
|
||||
previousSubtitle: { kind: 'none' as const },
|
||||
nextSubtitle: { kind: 'none' as const },
|
||||
replaySubtitle: { kind: 'none' as const },
|
||||
playNextSubtitle: { kind: 'none' as const },
|
||||
leftStickHorizontal: {
|
||||
kind: 'axis' as const,
|
||||
axisIndex: 0,
|
||||
@@ -140,6 +144,8 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
reportActiveController: () => {},
|
||||
getActiveControllerId: () => null,
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -226,6 +232,31 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
|
||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers stores the controller the overlay reports for Alt+C to ask', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
let active: string | null = null;
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
reportActiveController: (gamepadId) => {
|
||||
active = gamepadId;
|
||||
},
|
||||
getActiveControllerId: () => active,
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const report = handlers.on.get(IPC_CHANNELS.command.reportActiveController);
|
||||
report?.({}, 'pad-1');
|
||||
report?.({}, 42);
|
||||
assert.equal(
|
||||
await handlers.handle.get(IPC_CHANNELS.request.getActiveControllerId)?.({}),
|
||||
'pad-1',
|
||||
);
|
||||
report?.({}, null);
|
||||
assert.equal(await handlers.handle.get(IPC_CHANNELS.request.getActiveControllerId)?.({}), null);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -328,6 +359,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
reportActiveController: () => {},
|
||||
getActiveControllerId: () => null,
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
@@ -609,6 +642,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
reportActiveController: () => {},
|
||||
getActiveControllerId: () => null,
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
|
||||
@@ -89,6 +89,9 @@ export interface IpcServiceDeps {
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
/** The overlay reports the controller it is reading (null when none). */
|
||||
reportActiveController: (gamepadId: string | null) => void;
|
||||
getActiveControllerId: () => string | null;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
@@ -356,6 +359,9 @@ export interface IpcDepsRuntimeOptions {
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
/** The overlay reports the controller it is reading (null when none). */
|
||||
reportActiveController: (gamepadId: string | null) => void;
|
||||
getActiveControllerId: () => string | null;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
@@ -432,6 +438,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
reportActiveController: options.reportActiveController,
|
||||
getActiveControllerId: options.getActiveControllerId,
|
||||
getSecondarySubMode: options.getSecondarySubMode,
|
||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||
focusMainWindow: () => {
|
||||
@@ -760,6 +768,15 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.reportActiveController, (_event: unknown, gamepadId: unknown) => {
|
||||
if (gamepadId !== null && typeof gamepadId !== 'string') return;
|
||||
deps.reportActiveController(gamepadId);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getActiveControllerId, () => {
|
||||
return deps.getActiveControllerId();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||
return deps.getSecondarySubMode();
|
||||
});
|
||||
|
||||
+11
@@ -341,6 +341,8 @@ const texthookerService = new Texthooker(() => {
|
||||
},
|
||||
};
|
||||
});
|
||||
/** The controller the overlay is reading, as it last reported; null before it sees one. */
|
||||
let activeControllerIdFromOverlay: string | null = null;
|
||||
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler({
|
||||
platform: process.platform,
|
||||
});
|
||||
@@ -2083,11 +2085,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getMarkWatchedKey: () => configService.getConfig().stats.markWatchedKey,
|
||||
getOverlayNotificationPosition: () => configService.getConfig().notifications.overlayPosition,
|
||||
getControllerConfig: () => configService.getConfig().controller,
|
||||
// Alt+C opens in its own window, which cannot see a pad until a button is pressed in
|
||||
// it; the overlay has usually seen it already, so Alt+C asks which one is in use.
|
||||
reportActiveController: (gamepadId) => {
|
||||
activeControllerIdFromOverlay = gamepadId;
|
||||
},
|
||||
getActiveControllerId: () => activeControllerIdFromOverlay,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = configService.getRawConfig();
|
||||
configService.patchRawConfig({
|
||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
||||
});
|
||||
// The overlay reads controller settings once at startup, and Alt+C runs in its own
|
||||
// window, so remaps would otherwise only apply after a restart.
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.controllerConfigChanged);
|
||||
},
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
getRuntimeOptions: () => getRuntimeOptionsStateHandler(),
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||
reportActiveController: IpcDepsRuntimeOptions['reportActiveController'];
|
||||
getActiveControllerId: IpcDepsRuntimeOptions['getActiveControllerId'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||
@@ -256,6 +258,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
reportActiveController: params.reportActiveController,
|
||||
getActiveControllerId: params.getActiveControllerId,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
|
||||
@@ -373,6 +373,8 @@ export function createMpvMainEventBindingDeps(deps: {
|
||||
: undefined,
|
||||
recordPauseState: (paused: boolean) => {
|
||||
deps.appState.playbackPaused = paused;
|
||||
// Lets keyboard-only mode's auto-pause see the user playing or pausing themselves.
|
||||
deps.broadcastToOverlayWindows('mpv:pause-changed', paused);
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
||||
},
|
||||
|
||||
@@ -190,6 +190,13 @@ const onControllerSuspendedChangedEvent = createQueuedIpcListenerWithPayload<boo
|
||||
IPC_CHANNELS.event.controllerSuspendedChanged,
|
||||
(payload) => payload === true,
|
||||
);
|
||||
const onPlaybackPauseChangedEvent = createQueuedIpcListenerWithPayload<boolean>(
|
||||
IPC_CHANNELS.event.playbackPauseChanged,
|
||||
(payload) => payload === true,
|
||||
);
|
||||
const onControllerConfigChangedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.controllerConfigChanged,
|
||||
);
|
||||
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||
);
|
||||
@@ -346,6 +353,11 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update),
|
||||
reportActiveController: (gamepadId: string | null): void => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.reportActiveController, gamepadId);
|
||||
},
|
||||
getActiveControllerId: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getActiveControllerId),
|
||||
|
||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||
@@ -496,6 +508,8 @@ const electronAPI: ElectronAPI = {
|
||||
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onControllerSuspendedChanged: onControllerSuspendedChangedEvent,
|
||||
onControllerConfigChanged: onControllerConfigChangedEvent,
|
||||
onPlaybackPauseChanged: onPlaybackPauseChangedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
|
||||
|
||||
@@ -16,6 +16,7 @@ export function resolveControllerConfigForGamepad(
|
||||
if (!profile) return config;
|
||||
return {
|
||||
...config,
|
||||
faceButtonLabels: profile.faceButtonLabels ?? config.faceButtonLabels,
|
||||
buttonIndices: profile.buttonIndices,
|
||||
bindings: profile.bindings,
|
||||
reviewBindings: profile.reviewBindings ?? config.reviewBindings,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getControllerReviewInputContext,
|
||||
} from './controller-screen-layout.js';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||||
@@ -155,3 +156,33 @@ test('remapped duplicate card bindings replace the defaults', () => {
|
||||
|
||||
assert.deepEqual(actions, ['cancelMerge']);
|
||||
});
|
||||
|
||||
test('dialog hints follow the button style of the controller in use', () => {
|
||||
const config: ResolvedControllerConfig = {
|
||||
...CONFIG,
|
||||
profiles: {
|
||||
'pad-ps': {
|
||||
label: 'DualSense',
|
||||
faceButtonLabels: 'playstation',
|
||||
buttonIndices: CONFIG.buttonIndices,
|
||||
bindings: CONFIG.bindings,
|
||||
reviewBindings: CONFIG.reviewBindings,
|
||||
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
|
||||
},
|
||||
},
|
||||
};
|
||||
const hintsFor = (gamepadId: string) => {
|
||||
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
|
||||
return formatControllerScreenHints(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
padConfig.fieldGroupingBindings,
|
||||
padConfig,
|
||||
'standard',
|
||||
);
|
||||
};
|
||||
|
||||
assert.equal(hintsFor('pad-ps').continue, '╳');
|
||||
assert.equal(hintsFor('pad-ps').toggleView, '△');
|
||||
// Controllers without their own style use controller.faceButtonLabels.
|
||||
assert.equal(hintsFor('other-pad').continue, 'A');
|
||||
});
|
||||
@@ -70,6 +70,10 @@ const VOID_ACTIONS = [
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
|
||||
|
||||
@@ -114,6 +118,10 @@ function createHarness(
|
||||
previousEntry: () => {},
|
||||
nextEntry: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
previousSubtitle: () => {},
|
||||
nextSubtitle: () => {},
|
||||
replaySubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
setSecondarySubHeld: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
@@ -288,6 +296,7 @@ test('gamepad controller uses active controller profile bindings before global b
|
||||
profiles: {
|
||||
'pad-profile': {
|
||||
label: 'Profile Pad',
|
||||
faceButtonLabels: globalConfig.faceButtonLabels,
|
||||
buttonIndices: globalConfig.buttonIndices,
|
||||
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
|
||||
reviewBindings: globalConfig.reviewBindings,
|
||||
@@ -427,7 +436,8 @@ for (const c of [
|
||||
|
||||
harness.poll(0);
|
||||
|
||||
assert.deepEqual(harness.calls, c.fired ? ['playCurrentAudio', 'toggleMpvPause'] : []);
|
||||
// Pause works outside keyboard-only mode, so it is handled before the popup actions.
|
||||
assert.deepEqual(harness.calls, c.fired ? ['toggleMpvPause', 'playCurrentAudio'] : []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -784,3 +794,99 @@ test('gamepad controller still ignores a button held when the screen opened', ()
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:latch']);
|
||||
});
|
||||
|
||||
const SUBTITLE_NAVIGATION_BINDINGS = {
|
||||
previousSubtitle: { kind: 'button', buttonIndex: 10 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 11 },
|
||||
replaySubtitle: { kind: 'button', buttonIndex: 8 },
|
||||
playNextSubtitle: { kind: 'button', buttonIndex: 16 },
|
||||
} as const;
|
||||
|
||||
test('gamepad controller leaves subtitle navigation unbound by default', () => {
|
||||
for (const action of [
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const) {
|
||||
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings[action], { kind: 'none' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
test('gamepad controller navigates subtitles once per press, even outside keyboard-only mode', () => {
|
||||
let pressed: number[] = [];
|
||||
const harness = createHarness({
|
||||
gamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
|
||||
}),
|
||||
],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getKeyboardModeEnabled: () => false },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
for (const index of [11, 10, 8, 16]) {
|
||||
pressed = [index];
|
||||
// Holding the button does not repeat.
|
||||
harness.poll(16, 500, 1_000);
|
||||
pressed = [];
|
||||
harness.poll(1_016);
|
||||
}
|
||||
|
||||
assert.deepEqual(harness.calls, [
|
||||
'nextSubtitle',
|
||||
'previousSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
]);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses subtitle navigation while a blocking modal is open', () => {
|
||||
let blocked = true;
|
||||
let pressed = true;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 11: PRESSED } : {} })],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getInteractionBlocked: () => blocked },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
// Still held when the modal closes: not a fresh press.
|
||||
blocked = false;
|
||||
harness.poll(32);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
|
||||
pressed = false;
|
||||
harness.poll(48);
|
||||
pressed = true;
|
||||
harness.poll(64);
|
||||
assert.deepEqual(harness.calls, ['nextSubtitle']);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses mpv outside keyboard-only mode, but not under a blocking modal', () => {
|
||||
let blocked = false;
|
||||
let pressed = false;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 9: PRESSED } : {} })],
|
||||
config: createControllerConfig({
|
||||
bindings: { toggleMpvPause: { kind: 'button', buttonIndex: 9 } },
|
||||
}),
|
||||
deps: {
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getInteractionBlocked: () => blocked,
|
||||
},
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
pressed = true;
|
||||
// Held: fires once.
|
||||
harness.poll(16, 500);
|
||||
pressed = false;
|
||||
harness.poll(516);
|
||||
blocked = true;
|
||||
pressed = true;
|
||||
harness.poll(532);
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleMpvPause']);
|
||||
});
|
||||
@@ -18,6 +18,8 @@ export type GamepadLike = {
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
/** When the pad last reported new input (Gamepad API). */
|
||||
timestamp?: number;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
@@ -56,6 +58,10 @@ type GamepadControllerOptions = {
|
||||
previousEntry: () => void;
|
||||
nextEntry: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
previousSubtitle: () => void;
|
||||
nextSubtitle: () => void;
|
||||
replaySubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
setSecondarySubHeld: (held: boolean) => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
@@ -73,14 +79,16 @@ export type HoldState = {
|
||||
initialFired: boolean;
|
||||
};
|
||||
|
||||
const DPAD_BUTTON_INDEX = {
|
||||
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
|
||||
export const DPAD_BUTTON_INDEX = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
|
||||
const DPAD_AXIS_INDEX = {
|
||||
/** Axes some raw (non-standard) pads use for the D-pad. */
|
||||
export const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
@@ -276,6 +284,15 @@ const POPUP_ONLY_ACTIONS = [
|
||||
'nextEntry',
|
||||
] as const;
|
||||
|
||||
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
|
||||
const WATCHING_ACTIONS = [
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousActions = new Map<string, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
@@ -312,6 +329,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
timestamp: gamepad.timestamp,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
@@ -401,7 +419,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleMpvPause', config.bindings.toggleMpvPause],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
@@ -503,6 +521,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
),
|
||||
);
|
||||
|
||||
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
||||
if (activeConfig.enabled && !options.getInteractionBlocked()) {
|
||||
for (const actionKey of WATCHING_ACTIONS) {
|
||||
handleActionEdge(
|
||||
actionKey,
|
||||
activeConfig.bindings[actionKey],
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options[actionKey],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeConfig.enabled) {
|
||||
handleActionEdge(
|
||||
'toggleKeyboardOnlyMode',
|
||||
@@ -628,14 +659,6 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
handleActionEdge(
|
||||
'toggleMpvPause',
|
||||
activeConfig.bindings.toggleMpvPause,
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
|
||||
@@ -538,10 +538,15 @@ function createKeyboardHandlerHarness() {
|
||||
let changelogKeydownCount = 0;
|
||||
let jimakuKeydownCount = 0;
|
||||
|
||||
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
|
||||
let wordEventCount = 0;
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
dispatchEvent: () => {
|
||||
wordEventCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
|
||||
@@ -618,6 +623,7 @@ function createKeyboardHandlerHarness() {
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
jimakuKeydownCount: () => jimakuKeydownCount,
|
||||
wordEventCount: () => wordEventCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -2554,3 +2560,57 @@ test('controller popup entry navigation forwards Alt+Up / Alt+Down only while th
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
|
||||
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
// Without a popup, the lookup falls back to a synthetic scan shortly after.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
await wait(100);
|
||||
const afterFallback = wordEventCount();
|
||||
assert.ok(afterFallback > 0, 'the fallback scan runs');
|
||||
|
||||
// Leaving the mode before that fallback runs must cancel it.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(100);
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode closes an open popup', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const closeEvents = () =>
|
||||
testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' && event.visible === false,
|
||||
);
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
|
||||
// Turning the mode on again leaves a popup alone.
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
@@ -41,6 +41,8 @@ export function createKeyboardHandlers(
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
|
||||
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -550,6 +552,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
const changed = ctx.state.keyboardDrivenModeEnabled !== enabled;
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
@@ -560,6 +563,10 @@ export function createKeyboardHandlers(
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
// Leaving the mode means going back to watching: close the popup it was used for.
|
||||
// Done after the mode is off, so focus is not pulled back to the overlay.
|
||||
if (changed && !enabled) closeLookupWindow();
|
||||
if (changed) options.onKeyboardDrivenModeChanged?.(enabled);
|
||||
}
|
||||
|
||||
function toggleKeyboardDrivenMode(): void {
|
||||
@@ -756,6 +763,12 @@ export function createKeyboardHandlers(
|
||||
// released) and attention without a pane, so callers like the Kiku modal
|
||||
// opener leave nothing behind.
|
||||
function closeLookupWindow(): boolean {
|
||||
// Also when no popup shows yet: a lookup that has not opened one must not open it
|
||||
// later through its fallback scan (for example after leaving keyboard-only mode).
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
if (
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
@@ -764,10 +777,6 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
|
||||
@@ -509,6 +509,21 @@ test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
|
||||
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||
});
|
||||
|
||||
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
|
||||
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
|
||||
|
||||
// The popup hides under the pointer: hidden, then the leave it causes.
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
|
||||
|
||||
assert.equal(h.ctx.state.yomitanPopupVisible, false);
|
||||
assert.equal(h.ctx.state.isOverYomitanPopup, false);
|
||||
assert.equal(h.isInteractive(), false);
|
||||
});
|
||||
|
||||
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
|
||||
const h = createMouseHarness(t);
|
||||
h.ctx.state.primaryVisibleOnYomitanPopup = true;
|
||||
|
||||
@@ -505,7 +505,10 @@ export function createMouseHandlers(
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||
ctx.state.isOverYomitanPopup = false;
|
||||
reconcilePopupInteraction({ assumeVisible: true });
|
||||
// A popup that hides under a resting pointer also reports a leave, right after its
|
||||
// hidden event. Only keep a popup marked open if it still was; otherwise the next
|
||||
// keyboard or controller word move would look it up again and reopen it.
|
||||
reconcilePopupInteraction({ assumeVisible: yomitanPopupVisible });
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
|
||||
@@ -913,6 +913,10 @@
|
||||
<span>Preferred Controller</span>
|
||||
<select id="controllerSelectPicker"></select>
|
||||
</label>
|
||||
<label class="controller-select-field">
|
||||
<span>Button Style</span>
|
||||
<select id="controllerSelectFaceButtonStyle"></select>
|
||||
</label>
|
||||
<div id="controllerSelectSummary" class="controller-select-summary"></div>
|
||||
<div id="controllerConfigList" class="controller-config-list"></div>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause';
|
||||
|
||||
function setup(options: { enabled?: boolean; paused?: boolean | null } = {}) {
|
||||
const commands: string[] = [];
|
||||
const state = {
|
||||
enabled: options.enabled ?? true,
|
||||
paused: options.paused === undefined ? false : options.paused,
|
||||
};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => state.enabled,
|
||||
getPlaybackPaused: async () => state.paused,
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command.join(' '));
|
||||
if (command[1] === 'pause') {
|
||||
state.paused = command[2] === 'yes';
|
||||
// mpv reports the new pause state back.
|
||||
autoPause.handlePlaybackPauseChanged(state.paused);
|
||||
}
|
||||
},
|
||||
});
|
||||
/** The user pauses or plays from mpv, the controller or the keyboard. */
|
||||
const userSetsPause = (paused: boolean) => {
|
||||
state.paused = paused;
|
||||
autoPause.handlePlaybackPauseChanged(paused);
|
||||
};
|
||||
return { autoPause, commands, state, userSetsPause };
|
||||
}
|
||||
|
||||
test('entering keyboard-only mode pauses a playing video and leaving resumes it', async () => {
|
||||
const { autoPause, commands } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
assert.equal(autoPause.isPausedByMode(), true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes', 'set_property pause no']);
|
||||
});
|
||||
|
||||
test('the setting is off: keyboard-only mode leaves playback alone', async () => {
|
||||
const { autoPause, commands } = setup({ enabled: false });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('an already paused video stays paused when leaving the mode', async () => {
|
||||
const { autoPause, commands } = setup({ paused: true });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('playing the video yourself in the mode means leaving does not touch it', async () => {
|
||||
const { autoPause, commands, userSetsPause } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
userSetsPause(false);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes']);
|
||||
});
|
||||
|
||||
test('playing and pausing again yourself also keeps the video paused on leaving', async () => {
|
||||
const { autoPause, commands, state, userSetsPause } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
userSetsPause(false);
|
||||
userSetsPause(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes']);
|
||||
assert.equal(state.paused, true);
|
||||
});
|
||||
|
||||
test('leaving before the pause lookup returns does not pause afterwards', async () => {
|
||||
const commands: string[] = [];
|
||||
let answer: (paused: boolean) => void = () => {};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => true,
|
||||
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
|
||||
sendMpvCommand: (command) => commands.push(command.join(' ')),
|
||||
});
|
||||
|
||||
const entering = autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
answer(false);
|
||||
await entering;
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(autoPause.isPausedByMode(), false);
|
||||
});
|
||||
|
||||
test("a pause made while the pause lookup is pending stays the user's", async () => {
|
||||
const commands: string[] = [];
|
||||
let answer: (paused: boolean) => void = () => {};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => true,
|
||||
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
|
||||
sendMpvCommand: (command) => commands.push(command.join(' ')),
|
||||
});
|
||||
|
||||
const entering = autoPause.handleModeChanged(true);
|
||||
// The user pauses before the lookup answers; the answer still says "playing".
|
||||
autoPause.handlePlaybackPauseChanged(true);
|
||||
answer(false);
|
||||
await entering;
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, [], "leaving does not resume the user's pause");
|
||||
assert.equal(autoPause.isPausedByMode(), false);
|
||||
});
|
||||
|
||||
test('an unknown pause state is left alone', async () => {
|
||||
const { autoPause, commands } = setup({ paused: null });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
type MpvCommand = Array<string | number>;
|
||||
|
||||
/**
|
||||
* Optional pause while keyboard-only mode is on (`subtitleStyle.autoPauseVideoOnKeyboardMode`).
|
||||
*
|
||||
* Entering the mode pauses a playing video. Leaving it resumes only a pause the mode made
|
||||
* itself: if the video was already paused, or the user played or paused it while in the
|
||||
* mode, leaving does nothing. The user taking over is seen through mpv's pause changes.
|
||||
*/
|
||||
export function createKeyboardModeAutoPause(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
sendMpvCommand: (command: MpvCommand) => void;
|
||||
}) {
|
||||
let pausedByMode = false;
|
||||
/** Our own pause command is still to be reported back by mpv. */
|
||||
let awaitingOwnPause = false;
|
||||
/** Bumped on every mode change so a slow pause lookup cannot act on a stale mode. */
|
||||
let generation = 0;
|
||||
/** Bumped on every pause change, so a lookup answered after one is known to be stale. */
|
||||
let playbackChanges = 0;
|
||||
|
||||
async function enterMode(current: number): Promise<void> {
|
||||
if (!deps.isEnabled()) return;
|
||||
const changesBefore = playbackChanges;
|
||||
let paused: boolean | null = null;
|
||||
try {
|
||||
paused = await deps.getPlaybackPaused();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Left the mode again (or re-entered) while waiting: this request is stale. So is the
|
||||
// answer if the user paused or played meanwhile: that playback state is theirs.
|
||||
if (current !== generation || playbackChanges !== changesBefore || paused !== false) return;
|
||||
pausedByMode = true;
|
||||
awaitingOwnPause = true;
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
}
|
||||
|
||||
function leaveMode(): void {
|
||||
if (!pausedByMode) return;
|
||||
pausedByMode = false;
|
||||
awaitingOwnPause = false;
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Keyboard-only mode turned on or off, from the keyboard or the controller. */
|
||||
handleModeChanged(enabled: boolean): Promise<void> {
|
||||
generation += 1;
|
||||
if (enabled) return enterMode(generation);
|
||||
leaveMode();
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
/** mpv's pause property changed, from any source. */
|
||||
handlePlaybackPauseChanged(paused: boolean): void {
|
||||
playbackChanges += 1;
|
||||
if (!pausedByMode) return;
|
||||
if (paused && awaitingOwnPause) {
|
||||
awaitingOwnPause = false;
|
||||
return;
|
||||
}
|
||||
// Anything else (a resume, or a pause after one) is the user's own playback choice.
|
||||
pausedByMode = false;
|
||||
awaitingOwnPause = false;
|
||||
},
|
||||
|
||||
isPausedByMode: (): boolean => pausedByMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import { findControllerBindingIssues } from './controller-binding-issues.js';
|
||||
import { CONTROLLER_BINDING_DEFINITIONS } from './controller-config-form.js';
|
||||
|
||||
type Bindings = ResolvedControllerConfig['bindings'];
|
||||
const DEFAULTS = CORE_DEFAULT_CONFIG.controller.bindings as Bindings;
|
||||
|
||||
const issuesWith = (changes: Partial<Bindings>) =>
|
||||
findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, { ...DEFAULTS, ...changes });
|
||||
|
||||
test('the default bindings share no input', () => {
|
||||
assert.deepEqual(issuesWith({}), {});
|
||||
});
|
||||
|
||||
test('two actions on the same button both warn', () => {
|
||||
// Next Audio is on R1 (button 5) by default.
|
||||
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 5 } }), {
|
||||
nextSubtitle: 'Same as Next Audio',
|
||||
nextAudio: 'Same as Next Subtitle',
|
||||
});
|
||||
});
|
||||
|
||||
test('a button on the D-pad clashes with a D-pad row that is still on', () => {
|
||||
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 15 } }), {
|
||||
nextSubtitle: 'Same as Token Move (D-pad)',
|
||||
'leftStickHorizontal:dpad': 'Same as Next Subtitle',
|
||||
});
|
||||
// Clearing the D-pad row (as the docs advise) removes the warning.
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 15 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('a D-pad reported as a hat axis also counts as the D-pad', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({ previousEntry: { kind: 'axis', axisIndex: 7, direction: 'negative' } }),
|
||||
{
|
||||
previousEntry: 'Same as Popup Scroll (D-pad)',
|
||||
'leftStickVertical:dpad': 'Same as Previous Entry',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a stick direction clashes with a stick action on that axis', () => {
|
||||
const bindings = {
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
holdSecondarySub: { kind: 'axis', axisIndex: 3, direction: 'negative' },
|
||||
} as const;
|
||||
assert.deepEqual(issuesWith(bindings), {
|
||||
holdSecondarySub: 'Same as Popup Jump (Stick)',
|
||||
rightStickVertical: 'Same as Show Secondary Subtitle (Hold)',
|
||||
});
|
||||
});
|
||||
|
||||
test('opposite directions of one stick are different inputs', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
previousSubtitle: { kind: 'axis', axisIndex: 2, direction: 'negative' },
|
||||
nextSubtitle: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('two stick actions on the same stick both warn', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({ leftStickVertical: { kind: 'axis', axisIndex: 0, dpadFallback: 'vertical' } }),
|
||||
{
|
||||
leftStickHorizontal: 'Same as Popup Scroll (Stick)',
|
||||
leftStickVertical: 'Same as Token Move (Stick)',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alt Horizontal does nothing, so it never clashes', () => {
|
||||
// On standard pads the right stick's vertical axis is 3, the default of Alt Horizontal.
|
||||
assert.deepEqual(
|
||||
issuesWith({ rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' } }),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('a row shared by several actions lists them all', () => {
|
||||
assert.equal(
|
||||
issuesWith({
|
||||
mineCard: { kind: 'button', buttonIndex: 5 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 5 },
|
||||
}).nextAudio,
|
||||
'Same as Mine Card, Next Subtitle',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { DPAD_AXIS_INDEX, DPAD_BUTTON_INDEX } from '../handlers/gamepad-controller.js';
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerBindings = ResolvedControllerConfig['bindings'];
|
||||
type ControllerBindingActionId = keyof ControllerBindings;
|
||||
|
||||
/** What the checker needs from an Alt+C row definition. */
|
||||
export type ControllerBindingIssueDefinition = {
|
||||
id: ControllerBindingActionId;
|
||||
label: string;
|
||||
bindingType: 'discrete' | 'axis';
|
||||
};
|
||||
|
||||
/**
|
||||
* Rows that do nothing in SubMiner: no action reads `rightStickHorizontal`, so it cannot
|
||||
* clash with anything.
|
||||
*/
|
||||
const INACTIVE_BINDINGS: ReadonlySet<ControllerBindingActionId> = new Set(['rightStickHorizontal']);
|
||||
|
||||
/** Row key of a stick binding's D-pad row in Alt+C. */
|
||||
export function getControllerDpadRowKey(actionId: ControllerBindingActionId): string {
|
||||
return `${actionId}:dpad`;
|
||||
}
|
||||
|
||||
const buttonKey = (buttonIndex: number) => `button:${buttonIndex}`;
|
||||
const axisKey = (axisIndex: number, direction: 'negative' | 'positive') =>
|
||||
`axis:${axisIndex}:${direction}`;
|
||||
|
||||
function discreteInputs(binding: ResolvedControllerDiscreteBinding): string[] {
|
||||
if (binding.kind === 'none') return [];
|
||||
if ('buttonIndex' in binding) return [buttonKey(binding.buttonIndex)];
|
||||
return [axisKey(binding.axisIndex, binding.direction)];
|
||||
}
|
||||
|
||||
function stickInputs(binding: ResolvedControllerAxisBinding): string[] {
|
||||
if (binding.kind === 'none') return [];
|
||||
return [axisKey(binding.axisIndex, 'negative'), axisKey(binding.axisIndex, 'positive')];
|
||||
}
|
||||
|
||||
/** The D-pad is read from buttons 12-15 and, on some raw pads, from a hat axis. */
|
||||
function dpadInputs(fallback: ControllerDpadFallback): string[] {
|
||||
if (fallback === 'horizontal') {
|
||||
return [
|
||||
buttonKey(DPAD_BUTTON_INDEX.left),
|
||||
buttonKey(DPAD_BUTTON_INDEX.right),
|
||||
axisKey(DPAD_AXIS_INDEX.horizontal, 'negative'),
|
||||
axisKey(DPAD_AXIS_INDEX.horizontal, 'positive'),
|
||||
];
|
||||
}
|
||||
if (fallback === 'vertical') {
|
||||
return [
|
||||
buttonKey(DPAD_BUTTON_INDEX.up),
|
||||
buttonKey(DPAD_BUTTON_INDEX.down),
|
||||
axisKey(DPAD_AXIS_INDEX.vertical, 'negative'),
|
||||
axisKey(DPAD_AXIS_INDEX.vertical, 'positive'),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warnings for the main Alt+C list, keyed by row (the action id, or its D-pad row key):
|
||||
* a row warns when another row uses one of its inputs. Every such pair fires together at
|
||||
* some point, since the situations nest (always, then keyboard-only mode, then popup open),
|
||||
* so a shared input is always worth pointing out. Dialog layouts are checked separately:
|
||||
* they pause these actions while open.
|
||||
*/
|
||||
export function findControllerBindingIssues(
|
||||
definitions: readonly ControllerBindingIssueDefinition[],
|
||||
bindings: ControllerBindings,
|
||||
): Partial<Record<string, string>> {
|
||||
const rows: { key: string; label: string; inputs: string[] }[] = [];
|
||||
for (const definition of definitions) {
|
||||
if (INACTIVE_BINDINGS.has(definition.id)) continue;
|
||||
const binding = bindings[definition.id];
|
||||
if (!binding) continue;
|
||||
if (definition.bindingType === 'axis') {
|
||||
const axisBinding = binding as ResolvedControllerAxisBinding;
|
||||
rows.push({
|
||||
key: definition.id,
|
||||
label: `${definition.label} (Stick)`,
|
||||
inputs: stickInputs(axisBinding),
|
||||
});
|
||||
rows.push({
|
||||
key: getControllerDpadRowKey(definition.id),
|
||||
label: `${definition.label} (D-pad)`,
|
||||
inputs: dpadInputs(axisBinding.kind === 'none' ? 'none' : axisBinding.dpadFallback),
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
key: definition.id,
|
||||
label: definition.label,
|
||||
inputs: discreteInputs(binding as ResolvedControllerDiscreteBinding),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const issues: Partial<Record<string, string>> = {};
|
||||
for (const row of rows) {
|
||||
const inputs = new Set(row.inputs);
|
||||
const clashes = rows
|
||||
.filter((other) => other !== row && other.inputs.some((input) => inputs.has(input)))
|
||||
.map((other) => other.label);
|
||||
if (clashes.length > 0) issues[row.key] = `Same as ${clashes.join(', ')}`;
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import type { ControllerFaceButtonLabels, ResolvedControllerConfig } from '../../types';
|
||||
import type { ControllerScreenLearning } from './controller-config-form';
|
||||
import { createControllerConfigForm } from './controller-config-form.js';
|
||||
|
||||
@@ -24,6 +24,10 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -45,6 +49,12 @@ class FakeElement {
|
||||
this.className = [...new Set([...this.classes(), ...tokens])].join(' ');
|
||||
},
|
||||
contains: (token: string) => this.classes().includes(token),
|
||||
toggle: (token: string, force?: boolean) => {
|
||||
const on = force ?? !this.classes().includes(token);
|
||||
const rest = this.classes().filter((existing) => existing !== token);
|
||||
this.className = (on ? [...rest, token] : rest).join(' ');
|
||||
return on;
|
||||
},
|
||||
};
|
||||
|
||||
set innerHTML(value: string) {
|
||||
@@ -99,7 +109,7 @@ function renderForm(
|
||||
options: {
|
||||
bindings?: typeof BINDINGS;
|
||||
mapping?: string;
|
||||
faceButtonLabels?: 'xbox' | 'nintendo';
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
} = {},
|
||||
) {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
@@ -148,7 +158,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-group').map((el) => el.textContent),
|
||||
['Lookup', 'Playback', 'Popup Audio', 'Navigation'],
|
||||
['Lookup', 'Playback', 'Subtitles', 'Popup Audio', 'Navigation'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-row').map((row) => [
|
||||
@@ -165,6 +175,10 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
['Toggle MPV Pause', 'R3 / RS'],
|
||||
['Show Secondary Subtitle (Hold)', 'None'],
|
||||
['Quit MPV', 'Back / Select'],
|
||||
['Previous Subtitle', 'None'],
|
||||
['Next Subtitle', 'None'],
|
||||
['Replay Subtitle', 'None'],
|
||||
['Play Next Subtitle', 'None'],
|
||||
['Previous Audio', 'None'],
|
||||
['Next Audio', 'RB / R1'],
|
||||
['Play Current Audio', 'LB / L1'],
|
||||
@@ -180,7 +194,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(container, 'disabled').map((badge) => badge.textContent),
|
||||
['None', 'None', 'None', 'None', 'None', 'None'],
|
||||
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
|
||||
);
|
||||
assert.deepEqual(findAll(container, 'expanded'), []);
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
@@ -297,6 +311,7 @@ type ReviewFormOptions = {
|
||||
reviewBindings?: ResolvedControllerConfig['reviewBindings'];
|
||||
fieldGroupingBindings?: ResolvedControllerConfig['fieldGroupingBindings'];
|
||||
learning?: ControllerScreenLearning | null;
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
};
|
||||
|
||||
/** Renders the form with the dialog groups; `act` runs while `document` is still stubbed. */
|
||||
@@ -340,7 +355,7 @@ function withReviewForm(
|
||||
},
|
||||
getScreenInputContext: () => ({
|
||||
mapping: 'standard',
|
||||
faceButtonLabels: 'xbox',
|
||||
faceButtonLabels: options.faceButtonLabels ?? 'xbox',
|
||||
buttonIndices: CORE_DEFAULT_CONFIG.controller.buttonIndices,
|
||||
}),
|
||||
getScreenLearning: () => options.learning ?? null,
|
||||
@@ -430,6 +445,43 @@ test('controller config form warns about review bindings that clash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('controller config form warns when main actions share an input', () => {
|
||||
const { container, row } = renderForm(null, {
|
||||
bindings: { ...BINDINGS, nextSubtitle: { kind: 'button', buttonIndex: 15 } },
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-warning').map((el) => el.textContent),
|
||||
['\u26a0 Same as Token Move (D-pad)', '\u26a0 Same as Next Subtitle'],
|
||||
);
|
||||
assert.equal(row('Next Subtitle').classList.contains('has-warning'), true);
|
||||
assert.equal(row('Token Move (D-pad)').classList.contains('has-warning'), true);
|
||||
assert.equal(row('Next Audio').classList.contains('has-warning'), false);
|
||||
});
|
||||
|
||||
test('an edit panel slides in when it opens, not each time the list is redrawn', () => {
|
||||
withReviewForm({}, ({ container, reviewRow }) => {
|
||||
const panel = () => findOne(container, 'controller-config-edit-panel');
|
||||
const kept = () => panel().classList.contains('controller-config-edit-panel-kept');
|
||||
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.equal(kept(), false, 'opening animates');
|
||||
|
||||
// Any other click redraws the whole list; the open panel is recreated as it was.
|
||||
findOne(container, 'controller-config-group-toggle').click();
|
||||
assert.equal(kept(), true);
|
||||
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.equal(kept(), false, 'reopening animates again');
|
||||
|
||||
reviewRow('Close Lookup').click();
|
||||
assert.equal(kept(), false, 'another row opening animates');
|
||||
});
|
||||
});
|
||||
|
||||
/** The dialog group headers, in order. */
|
||||
const groupHeaders = (container: FakeElement) =>
|
||||
findAll(container, 'controller-config-group-toggle');
|
||||
@@ -483,3 +535,30 @@ test('learning a duplicate card action opens only that group', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('controller config form names face buttons with PlayStation symbols when configured', () => {
|
||||
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
|
||||
const bindings: typeof BINDINGS = {
|
||||
...BINDINGS,
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
};
|
||||
|
||||
const form = renderForm(null, { bindings, mapping: 'standard', faceButtonLabels: 'playstation' });
|
||||
assert.equal(badge(form.row('Toggle Lookup')), '╳');
|
||||
assert.equal(badge(form.row('Close Lookup')), '○');
|
||||
assert.equal(badge(form.row('Mine Card')), '□');
|
||||
assert.equal(badge(form.row('Toggle Keyboard-Only Mode')), '△');
|
||||
assert.equal(badge(form.row('Next Audio')), 'RB / R1');
|
||||
|
||||
// The dialog groups print the same symbols.
|
||||
withReviewForm({ faceButtonLabels: 'playstation' }, ({ container, reviewRow }) => {
|
||||
findOne(container, 'controller-config-group-toggle').click();
|
||||
assert.equal(badgeOf(reviewRow('Use this timing')), '╳');
|
||||
assert.equal(badgeOf(reviewRow('Keep editing')), '○');
|
||||
assert.equal(badgeOf(reviewRow('Play selection')), '□');
|
||||
assert.equal(badgeOf(reviewRow('Reset timing')), '△');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
findControllerScreenBindingIssues,
|
||||
getControllerFaceButtonName,
|
||||
formatControllerReviewBinding,
|
||||
type ControllerReviewInputContext,
|
||||
type ControllerScreenActionDefinition,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
type ControllerScreenDefinition,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ControllerScreenId } from '../../shared/controller-screens';
|
||||
import {
|
||||
findControllerBindingIssues,
|
||||
getControllerDpadRowKey,
|
||||
} from './controller-binding-issues.js';
|
||||
import type { ResolvedControllerReviewBinding } from '../../types';
|
||||
|
||||
/** A dialog layout shown as a collapsible group in Alt+C. */
|
||||
@@ -102,6 +107,34 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
{
|
||||
id: 'previousSubtitle',
|
||||
label: 'Previous Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'nextSubtitle',
|
||||
label: 'Next Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'replaySubtitle',
|
||||
label: 'Replay Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'playNextSubtitle',
|
||||
label: 'Play Next Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'previousAudio',
|
||||
label: 'Previous Audio',
|
||||
@@ -240,9 +273,6 @@ const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
|
||||
type ControllerMapping = string | null | undefined;
|
||||
type FaceButtonLabels = ResolvedControllerConfig['faceButtonLabels'] | undefined;
|
||||
|
||||
/** Nintendo-style pads print B at the bottom and A on the right; positions 0-3 are the same. */
|
||||
const NINTENDO_FACE_BUTTON_NAMES: Record<number, string> = { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' };
|
||||
|
||||
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
||||
none: 'None',
|
||||
horizontal: 'D-pad \u2194',
|
||||
@@ -254,9 +284,13 @@ export function getFriendlyButtonName(
|
||||
mapping?: ControllerMapping,
|
||||
faceButtonLabels?: FaceButtonLabels,
|
||||
): string {
|
||||
const nintendoName =
|
||||
faceButtonLabels === 'nintendo' ? NINTENDO_FACE_BUTTON_NAMES[buttonIndex] : undefined;
|
||||
if (nintendoName) return nintendoName;
|
||||
// The default Xbox style keeps the shared "A / Cross" names; the others print their own.
|
||||
// Face positions 0-3 are the same on raw and standard layouts.
|
||||
const styledName =
|
||||
faceButtonLabels && faceButtonLabels !== 'xbox'
|
||||
? getControllerFaceButtonName(buttonIndex, faceButtonLabels)
|
||||
: null;
|
||||
if (styledName) return styledName;
|
||||
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
|
||||
return names[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
@@ -326,6 +360,8 @@ export function createControllerConfigForm(options: {
|
||||
getControllerMapping?: () => ControllerMapping;
|
||||
/** `controller.faceButtonLabels`; names the face buttons as printed on the pad. */
|
||||
getFaceButtonLabels?: () => FaceButtonLabels;
|
||||
/** True until the window sees a controller: rows are a preview and actions are dimmed. */
|
||||
isEditingLocked?: () => boolean;
|
||||
/** Dialog layouts (review, field grouping) of the controller being configured. */
|
||||
getScreenGroups?: () => ControllerScreenGroup[];
|
||||
getScreenInputContext?: () => ControllerReviewInputContext;
|
||||
@@ -336,14 +372,35 @@ export function createControllerConfigForm(options: {
|
||||
onScreenResetAll?: (screenId: ControllerScreenId) => void;
|
||||
}) {
|
||||
let expandedRowKey: ExpandedRowKey | null = null;
|
||||
/**
|
||||
* Row whose edit panel the previous render showed. Every change rebuilds the list, so a
|
||||
* panel that stays open is recreated; it only slides in when it was not open before.
|
||||
*/
|
||||
let previousPanelRowKey: ExpandedRowKey | null = null;
|
||||
let panelRowKey: ExpandedRowKey | null = null;
|
||||
/** Dialog groups start collapsed; this holds the opened ones. */
|
||||
const expandedScreens = new Set<ControllerScreenId>();
|
||||
|
||||
function render(): void {
|
||||
panelRowKey = null;
|
||||
try {
|
||||
renderRows();
|
||||
} finally {
|
||||
previousPanelRowKey = panelRowKey;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(): void {
|
||||
options.container.innerHTML = '';
|
||||
options.container.classList?.toggle(
|
||||
'controller-config-locked',
|
||||
options.isEditingLocked?.() ?? false,
|
||||
);
|
||||
let lastGroup = '';
|
||||
const learningActionId = options.getLearningActionId();
|
||||
const dpadLearningActionId = options.getDpadLearningActionId();
|
||||
const bindings = options.getBindings();
|
||||
const issues = findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, bindings);
|
||||
|
||||
// Auto-expand when learning starts
|
||||
if (learningActionId) {
|
||||
@@ -361,17 +418,23 @@ export function createControllerConfigForm(options: {
|
||||
lastGroup = definition.group;
|
||||
}
|
||||
|
||||
const binding = options.getBindings()[definition.id];
|
||||
const binding = bindings[definition.id];
|
||||
|
||||
if (definition.bindingType === 'axis') {
|
||||
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
|
||||
renderAxisStickRow(
|
||||
definition,
|
||||
binding as ResolvedControllerAxisBinding,
|
||||
learningActionId,
|
||||
issues[definition.id],
|
||||
);
|
||||
renderAxisDpadRow(
|
||||
definition,
|
||||
binding as ResolvedControllerAxisBinding,
|
||||
dpadLearningActionId,
|
||||
issues[getControllerDpadRowKey(definition.id)],
|
||||
);
|
||||
} else {
|
||||
renderDiscreteRow(definition, binding, learningActionId);
|
||||
renderDiscreteRow(definition, binding, learningActionId, issues[definition.id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +548,7 @@ export function createControllerConfigForm(options: {
|
||||
: holdLabel
|
||||
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
|
||||
: 'Press a button, or hold one and press another for a combo\u2026';
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: learn,
|
||||
onClear: (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -501,6 +564,7 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
@@ -526,6 +590,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -537,7 +602,7 @@ export function createControllerConfigForm(options: {
|
||||
const hint = isLearning
|
||||
? 'Press a button, trigger, or move a stick\u2026'
|
||||
: `Currently: ${formatControllerBindingSummary(binding)}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onLearn(definition.id, definition.bindingType);
|
||||
@@ -559,6 +624,7 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
@@ -580,6 +646,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -590,7 +657,7 @@ export function createControllerConfigForm(options: {
|
||||
if (isExpanded) {
|
||||
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
|
||||
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onLearn(definition.id, 'axis');
|
||||
@@ -612,8 +679,9 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
dpadLearningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = `${definition.id as string}:dpad`;
|
||||
const rowKey = getControllerDpadRowKey(definition.id);
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = dpadLearningActionId === definition.id;
|
||||
|
||||
@@ -636,6 +704,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onDpadReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -647,7 +716,7 @@ export function createControllerConfigForm(options: {
|
||||
const hint = isLearning
|
||||
? 'Press a D-pad direction\u2026'
|
||||
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onDpadLearn(definition.id);
|
||||
@@ -729,6 +798,7 @@ export function createControllerConfigForm(options: {
|
||||
}
|
||||
|
||||
function createEditPanel(
|
||||
rowKey: ExpandedRowKey,
|
||||
hintText: string,
|
||||
isLearning: boolean,
|
||||
callbacks: {
|
||||
@@ -739,6 +809,8 @@ export function createControllerConfigForm(options: {
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'controller-config-edit-panel';
|
||||
if (rowKey === previousPanelRowKey) panel.classList.add('controller-config-edit-panel-kept');
|
||||
panelRowKey = rowKey;
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'controller-config-edit-inner';
|
||||
|
||||
@@ -77,6 +77,10 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -198,6 +202,10 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -318,6 +326,10 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
|
||||
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerSelectModal } from './controller-select.js';
|
||||
import {
|
||||
createControllerSelectModal,
|
||||
findMostRecentlyUsedIndex,
|
||||
shortControllerName,
|
||||
} from './controller-select.js';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
|
||||
@@ -159,6 +163,10 @@ function buildContext() {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -179,6 +187,7 @@ function buildContext() {
|
||||
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
controllerSelectClose: createFakeElement(),
|
||||
controllerSelectPicker: createFakeElement(),
|
||||
controllerSelectFaceButtonStyle: createFakeElement(),
|
||||
controllerSelectSummary: createFakeElement(),
|
||||
controllerConfigList: createFakeElement(),
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
@@ -328,6 +337,7 @@ test('controller select modal reset control stores the default binding in the se
|
||||
state.controllerConfig.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
faceButtonLabels: state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: state.controllerConfig.buttonIndices,
|
||||
bindings: {
|
||||
...state.controllerConfig.bindings,
|
||||
@@ -429,7 +439,7 @@ test('controller select modal binding badge starts learn mode and persists bindi
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
|
||||
test('controller select modal locks editing until this window sees a controller', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
@@ -448,44 +458,45 @@ test('controller select modal learn mode falls back to global bindings without a
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
// A freshly opened window sees no pad until a button is pressed in it.
|
||||
state.connectedGamepads = [];
|
||||
// No controller visible and none saved: edits go to the global bindings.
|
||||
state.controllerConfig!.preferredGamepadId = '';
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
firstRow.dispatch('click');
|
||||
const editPanel = dom.controllerConfigList.children[2];
|
||||
const learnButton = editPanel.children[0].children[1].children[0];
|
||||
learnButton.dispatch('click');
|
||||
const press = 'Press any button on your controller to edit its bindings.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), true);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, true);
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
// Learn and Clear do nothing yet.
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Toggle Lookup');
|
||||
row.children[1].children[0].dispatch('click');
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, (_, index) => ({
|
||||
value: index === 11 ? 1 : 0,
|
||||
pressed: index === 11,
|
||||
touched: index === 11,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved, []);
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press, 'kept across frames');
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
// The press makes the pad visible: editing unlocks for it.
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), false);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, false);
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Choose a controller or click Learn to remap an action.',
|
||||
);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
@@ -799,7 +810,7 @@ test('controller select modal learns a duplicate card button and saves it to the
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal shows and edits the saved controller before this window sees it', async () => {
|
||||
test('controller select modal previews the saved controller, then edits it once seen', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
@@ -825,6 +836,7 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
config.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
faceButtonLabels: config.faceButtonLabels,
|
||||
buttonIndices: config.buttonIndices,
|
||||
bindings: config.bindings,
|
||||
fieldGroupingBindings: config.fieldGroupingBindings,
|
||||
@@ -846,18 +858,31 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
// The preview is the saved controller's layout, labelled as such.
|
||||
assert.equal(dom.controllerSelectSummary.textContent, 'Showing pad-1 (saved).');
|
||||
findByClass(dom.controllerConfigList, 'controller-config-group-toggle').dispatch('click');
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
|
||||
assert.equal(row.children[1].children[0].textContent, 'L2 + D-pad \u25b6');
|
||||
const waiting = 'Press any button on your controller so this window can see it.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
const startBack = () => findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
|
||||
assert.equal(startBack().children[1].children[0].textContent, 'L2 + D-pad \u25b6');
|
||||
const press = 'Press any button on your controller to edit its bindings.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
// Controller polling keeps publishing snapshots while no pad is visible.
|
||||
modal.updateDevices();
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting, 'the prompt stays');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press, 'the prompt stays');
|
||||
|
||||
// Reset goes to the saved controller's profile, not the global layout.
|
||||
row.children[1].children[1].dispatch('click');
|
||||
// Reset is locked until the window sees the pad.
|
||||
startBack().children[1].children[1].dispatch('click');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved, []);
|
||||
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Choose a controller or click Learn to remap an action.',
|
||||
);
|
||||
startBack().children[1].children[1].dispatch('click');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
@@ -873,19 +898,19 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const afterReset = dom.controllerSelectStatus.textContent;
|
||||
assert.equal(afterReset, 'Start \u22120.1s reset.');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, 'Start \u22120.1s reset.');
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, afterReset, 'the reset message stays');
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Start \u22120.1s reset.',
|
||||
'the reset message stays',
|
||||
);
|
||||
|
||||
// The pad appears, then disappears again.
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
// The pad disappears, then comes back.
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
@@ -897,3 +922,173 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal previews the controller the overlay reports in use', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
getActiveControllerId: async () => 'DualSense Wireless Controller (STANDARD GAMEPAD)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const config = state.controllerConfig!;
|
||||
// The saved controller is pad-1, but the overlay is reading the DualSense.
|
||||
config.profiles = {
|
||||
'DualSense Wireless Controller (STANDARD GAMEPAD)': {
|
||||
label: 'DualSense',
|
||||
faceButtonLabels: 'playstation',
|
||||
buttonIndices: config.buttonIndices,
|
||||
bindings: { ...config.bindings, mineCard: { kind: 'button', buttonIndex: 3 } },
|
||||
reviewBindings: config.reviewBindings,
|
||||
fieldGroupingBindings: config.fieldGroupingBindings,
|
||||
},
|
||||
};
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
dom.controllerSelectSummary.textContent,
|
||||
'Showing DualSense Wireless Controller (in use).',
|
||||
);
|
||||
assert.equal(
|
||||
findReviewRow(dom.controllerConfigList, 'Mine Card').children[1].children[0].textContent,
|
||||
'\u25b3',
|
||||
);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.value, 'playstation');
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal selects the controller you pressed when several appear', () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: { saveControllerConfig: async () => {}, notifyOverlayModalClosed: () => {} },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
// One press reveals every pad; pad-2 has the newest input, pad-1 is the saved one.
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true, timestamp: 100 },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true, timestamp: 500 },
|
||||
];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
assert.equal(state.controllerSelectGamepadId, 'pad-2', 'this window then reads pad-2');
|
||||
|
||||
modal.closeControllerSelectModal();
|
||||
assert.equal(state.controllerSelectGamepadId, null);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal shows and saves the button style per controller', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const stylePicker = dom.controllerSelectFaceButtonStyle;
|
||||
assert.deepEqual(
|
||||
stylePicker.children.map((option: any) => option.value),
|
||||
['xbox', 'nintendo', 'playstation'],
|
||||
);
|
||||
assert.equal(stylePicker.value, 'xbox');
|
||||
|
||||
stylePicker.value = 'playstation';
|
||||
stylePicker.dispatch('change');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
|
||||
});
|
||||
assert.equal(state.controllerConfig?.profiles['pad-1']?.faceButtonLabels, 'playstation');
|
||||
assert.equal(state.controllerConfig?.faceButtonLabels, 'xbox', 'other controllers keep theirs');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, 'Button style: PlayStation.');
|
||||
// Toggle Lookup is on the bottom face button.
|
||||
assert.equal(
|
||||
findReviewRow(dom.controllerConfigList, 'Toggle Lookup').children[1].children[0].textContent,
|
||||
'╳',
|
||||
);
|
||||
|
||||
// The other controller still uses the default style.
|
||||
dom.controllerSelectPicker.value = 'pad-2:1';
|
||||
dom.controllerSelectPicker.dispatch('change');
|
||||
assert.equal(stylePicker.value, 'xbox');
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shortControllerName drops the driver details in brackets', () => {
|
||||
assert.equal(
|
||||
shortControllerName('Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)'),
|
||||
'Xbox Wireless Controller',
|
||||
);
|
||||
assert.equal(shortControllerName('pad-1'), 'pad-1');
|
||||
assert.equal(shortControllerName('(only details)'), '(only details)');
|
||||
});
|
||||
|
||||
test('findMostRecentlyUsedIndex picks the newest input and ignores missing timestamps', () => {
|
||||
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 100 }, { timestamp: 500 }, {}]), 1);
|
||||
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 900 }, { timestamp: Number.NaN }]), 0);
|
||||
assert.equal(findMostRecentlyUsedIndex([{}, { timestamp: 0 }]), -1);
|
||||
assert.equal(findMostRecentlyUsedIndex([]), -1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { resolveControllerConfigForGamepad } from '../controller-profile-config.
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
|
||||
import {
|
||||
CONTROLLER_FACE_BUTTON_STYLES,
|
||||
formatControllerReviewInput,
|
||||
getControllerScreenAction,
|
||||
getDefaultControllerScreenBindings,
|
||||
@@ -22,6 +23,27 @@ import {
|
||||
getDefaultDpadFallback,
|
||||
} from './controller-config-form.js';
|
||||
|
||||
const PRESS_TO_EDIT_STATUS = 'Press any button on your controller to edit its bindings.';
|
||||
|
||||
/** `8BitDo Ultimate Wireless Controller (STANDARD GAMEPAD Vendor: …)` → the name part. */
|
||||
export function shortControllerName(gamepadId: string): string {
|
||||
return gamepadId.replace(/\s*\(.*\)\s*$/, '').trim() || gamepadId;
|
||||
}
|
||||
|
||||
/** Index of the pad with the newest input (Gamepad API timestamp), or -1 if none report one. */
|
||||
export function findMostRecentlyUsedIndex(devices: ReadonlyArray<{ timestamp?: number }>): number {
|
||||
let best = -1;
|
||||
let bestTimestamp = 0;
|
||||
devices.forEach((device, index) => {
|
||||
const timestamp = device.timestamp ?? 0;
|
||||
if (Number.isFinite(timestamp) && timestamp > bestTimestamp) {
|
||||
best = index;
|
||||
bestTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function clampSelectedIndex(ctx: RendererContext): void {
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = 0;
|
||||
@@ -34,11 +56,6 @@ function clampSelectedIndex(ctx: RendererContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const NO_CONTROLLER_STATUS = 'No controllers detected.';
|
||||
const WAITING_FOR_SAVED_CONTROLLER_STATUS =
|
||||
'Press any button on your controller so this window can see it.';
|
||||
const NO_CONTROLLER_STATUSES = new Set([NO_CONTROLLER_STATUS, WAITING_FOR_SAVED_CONTROLLER_STATUS]);
|
||||
|
||||
export function createControllerSelectModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -65,17 +82,52 @@ export function createControllerSelectModal(
|
||||
holdLabel: string | null;
|
||||
} | null = null;
|
||||
|
||||
/** The controller the overlay reports in use, fetched when the window opens. */
|
||||
let overlayActiveControllerId: string | null = null;
|
||||
|
||||
async function loadOverlayActiveController(): Promise<void> {
|
||||
let gamepadId: string | null = null;
|
||||
try {
|
||||
gamepadId = (await window.electronAPI.getActiveControllerId?.()) ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!ctx.state.controllerSelectModalOpen || gamepadId === overlayActiveControllerId) return;
|
||||
overlayActiveControllerId = gamepadId;
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
function getSelectedController() {
|
||||
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller whose profile Alt+C shows and edits. A freshly opened window cannot see
|
||||
* any pad until a button is pressed in it, so until then use the saved (preferred)
|
||||
* controller rather than the global defaults it does not actually use.
|
||||
* any pad until a button is pressed in it; until then it previews the controller the
|
||||
* overlay reports in use, else the saved (preferred) one, and editing stays locked.
|
||||
*/
|
||||
function getSelectedControllerId(): string | null {
|
||||
return getSelectedController()?.id ?? (ctx.state.controllerConfig?.preferredGamepadId || null);
|
||||
return (
|
||||
getSelectedController()?.id ??
|
||||
overlayActiveControllerId ??
|
||||
(ctx.state.controllerConfig?.preferredGamepadId || null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing can be changed until this window sees a controller: before that it could only
|
||||
* guess which controller you mean, and a change could land on the wrong one.
|
||||
*/
|
||||
function isEditingLocked(): boolean {
|
||||
return ctx.state.connectedGamepads.length === 0;
|
||||
}
|
||||
|
||||
/** True when editing may go ahead; otherwise says why not. */
|
||||
function canEdit(): boolean {
|
||||
if (!isEditingLocked()) return true;
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSelectedControllerConfig() {
|
||||
@@ -115,8 +167,9 @@ export function createControllerSelectModal(
|
||||
|
||||
const controllerConfigForm = createControllerConfigForm({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
isEditingLocked,
|
||||
getControllerMapping: () => getSelectedController()?.mapping ?? null,
|
||||
getFaceButtonLabels: () => ctx.state.controllerConfig?.faceButtonLabels,
|
||||
getFaceButtonLabels: () => getSelectedControllerConfig()?.faceButtonLabels,
|
||||
getBindings: () =>
|
||||
getSelectedControllerConfig()?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
@@ -131,6 +184,10 @@ export function createControllerSelectModal(
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -154,6 +211,7 @@ export function createControllerSelectModal(
|
||||
}
|
||||
: null,
|
||||
onScreenLearn: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
@@ -171,6 +229,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenClear: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -179,6 +238,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenReset: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -187,6 +247,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenResetAll: (screenId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -197,6 +258,7 @@ export function createControllerSelectModal(
|
||||
getLearningActionId: () => learningActionId,
|
||||
getDpadLearningActionId: () => dpadLearningActionId,
|
||||
onLearn: (actionId, bindingType) => {
|
||||
if (!canEdit()) return;
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
dpadLearningActionId = null;
|
||||
@@ -232,12 +294,15 @@ export function createControllerSelectModal(
|
||||
setStatus(`Waiting for input for ${definition.label}.`);
|
||||
},
|
||||
onClear: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveBinding(actionId, { kind: 'none' });
|
||||
},
|
||||
onReset: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveBinding(actionId, getDefaultControllerBinding(actionId));
|
||||
},
|
||||
onDpadLearn: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
learningActionId = null;
|
||||
@@ -259,9 +324,11 @@ export function createControllerSelectModal(
|
||||
setStatus(`Press a D-pad direction for ${definition.label}.`);
|
||||
},
|
||||
onDpadClear: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveDpadFallback(actionId, 'none');
|
||||
},
|
||||
onDpadReset: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
|
||||
},
|
||||
});
|
||||
@@ -279,9 +346,18 @@ export function createControllerSelectModal(
|
||||
function syncSelectedControllerId(): void {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
||||
// This window then reads that controller, so Learn hears the pad being configured.
|
||||
ctx.state.controllerSelectGamepadId = selected?.id ?? null;
|
||||
}
|
||||
|
||||
function syncSelectedIndexToCurrentController(): void {
|
||||
// The pad you just pressed (newest input) is the one in your hands.
|
||||
const recentIndex = findMostRecentlyUsedIndex(ctx.state.connectedGamepads);
|
||||
if (recentIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = recentIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
@@ -331,12 +407,58 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
||||
ctx.dom.controllerSelectSummary.textContent =
|
||||
ctx.state.connectedGamepads.length === 0
|
||||
? 'No controller detected.'
|
||||
? describePreview()
|
||||
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
||||
|
||||
lastRenderedDevicesKey = getDevicesKey();
|
||||
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
||||
lastRenderedPreferredId = preferredId;
|
||||
renderFaceButtonStylePicker();
|
||||
}
|
||||
|
||||
/** Which controller the locked preview shows, before this window sees one. */
|
||||
function describePreview(): string {
|
||||
const previewId = getSelectedControllerId();
|
||||
if (!previewId) return 'No controller detected.';
|
||||
const source = previewId === overlayActiveControllerId ? 'in use' : 'saved';
|
||||
return `Showing ${shortControllerName(previewId)} (${source}).`;
|
||||
}
|
||||
|
||||
/** Shows the button style of the controller being configured (its own, else the default). */
|
||||
function renderFaceButtonStylePicker(): void {
|
||||
const picker = ctx.dom.controllerSelectFaceButtonStyle;
|
||||
picker.disabled = isEditingLocked();
|
||||
const current = getSelectedControllerConfig()?.faceButtonLabels ?? 'xbox';
|
||||
picker.innerHTML = '';
|
||||
for (const style of CONTROLLER_FACE_BUTTON_STYLES) {
|
||||
const option = document.createElement('option');
|
||||
option.value = style.id;
|
||||
option.textContent = `${style.name} (${style.hint})`;
|
||||
option.selected = style.id === current;
|
||||
picker.appendChild(option);
|
||||
}
|
||||
picker.value = current;
|
||||
}
|
||||
|
||||
/** Saved for the controller being configured, like its remaps. */
|
||||
async function saveFaceButtonStyle(
|
||||
style: (typeof CONTROLLER_FACE_BUTTON_STYLES)[number],
|
||||
): Promise<void> {
|
||||
const profileId = getSelectedControllerId();
|
||||
try {
|
||||
await saveControllerConfig(
|
||||
profileId
|
||||
? { profiles: { [profileId]: { faceButtonLabels: style.id } } }
|
||||
: { faceButtonLabels: style.id },
|
||||
);
|
||||
renderFaceButtonStylePicker();
|
||||
controllerConfigForm.render();
|
||||
setStatus(`Button style: ${style.name}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save button style: ${message}`, true);
|
||||
renderFaceButtonStylePicker();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveControllerConfig(
|
||||
@@ -350,6 +472,9 @@ export function createControllerSelectModal(
|
||||
if (update.preferredGamepadLabel !== undefined) {
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
||||
}
|
||||
if (update.faceButtonLabels) {
|
||||
ctx.state.controllerConfig.faceButtonLabels = update.faceButtonLabels;
|
||||
}
|
||||
if (update.bindings) {
|
||||
ctx.state.controllerConfig.bindings = {
|
||||
...ctx.state.controllerConfig.bindings,
|
||||
@@ -371,6 +496,7 @@ export function createControllerSelectModal(
|
||||
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
|
||||
const baseProfile = currentProfile ?? {
|
||||
label: profileUpdate.label ?? profileId,
|
||||
faceButtonLabels: ctx.state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
||||
bindings: ctx.state.controllerConfig.bindings,
|
||||
reviewBindings: ctx.state.controllerConfig.reviewBindings,
|
||||
@@ -378,6 +504,10 @@ export function createControllerSelectModal(
|
||||
};
|
||||
ctx.state.controllerConfig.profiles[profileId] = {
|
||||
label: profileUpdate.label ?? baseProfile.label,
|
||||
faceButtonLabels:
|
||||
profileUpdate.faceButtonLabels ??
|
||||
baseProfile.faceButtonLabels ??
|
||||
ctx.state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: {
|
||||
...baseProfile.buttonIndices,
|
||||
...(profileUpdate.buttonIndices ?? {}),
|
||||
@@ -568,33 +698,27 @@ export function createControllerSelectModal(
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
// Polling publishes a snapshot every frame, pad or not: only a change in the devices
|
||||
// sets the no-pad status, so it neither replaces the prompt nor a message like "Reset".
|
||||
// Polling publishes a snapshot every frame, pad or not. Until the window sees a pad,
|
||||
// editing is locked, so the only status is the prompt; it is set once, not every frame.
|
||||
const idle = !learningActionId && !dpadLearningActionId && !screenLearning;
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
if (idle && shouldRender) setStatus(getNoControllerStatus());
|
||||
} else if (
|
||||
idle &&
|
||||
NO_CONTROLLER_STATUSES.has(ctx.dom.controllerSelectStatus.textContent ?? '')
|
||||
) {
|
||||
if (ctx.state.connectedGamepads.length === 0 && idle) {
|
||||
if (ctx.dom.controllerSelectStatus.textContent !== PRESS_TO_EDIT_STATUS) {
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
}
|
||||
} else if (idle && ctx.dom.controllerSelectStatus.textContent === PRESS_TO_EDIT_STATUS) {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved controller is only waiting for a press; without one there is nothing to show. */
|
||||
function getNoControllerStatus(): string {
|
||||
return ctx.state.controllerConfig?.preferredGamepadId
|
||||
? WAITING_FOR_SAVED_CONTROLLER_STATUS
|
||||
: NO_CONTROLLER_STATUS;
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): boolean {
|
||||
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||
options.notifyControllerDisabled();
|
||||
return false;
|
||||
}
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
overlayActiveControllerId = null;
|
||||
syncSelectedIndexToCurrentController();
|
||||
void loadOverlayActiveController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
||||
@@ -604,7 +728,7 @@ export function createControllerSelectModal(
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus(getNoControllerStatus());
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
} else {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
@@ -618,6 +742,7 @@ export function createControllerSelectModal(
|
||||
bindingCapture = null;
|
||||
cancelScreenLearning();
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
ctx.state.controllerSelectGamepadId = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
||||
@@ -687,6 +812,15 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||
void saveSelectedController();
|
||||
});
|
||||
ctx.dom.controllerSelectFaceButtonStyle.addEventListener('change', () => {
|
||||
if (!canEdit()) {
|
||||
renderFaceButtonStylePicker();
|
||||
return;
|
||||
}
|
||||
const value = ctx.dom.controllerSelectFaceButtonStyle.value;
|
||||
const style = CONTROLLER_FACE_BUTTON_STYLES.find((candidate) => candidate.id === value);
|
||||
if (style) void saveFaceButtonStyle(style);
|
||||
});
|
||||
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
||||
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
||||
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
|
||||
+118
-47
@@ -24,6 +24,7 @@ import type {
|
||||
SubtitlePosition,
|
||||
SubsyncManualPayload,
|
||||
ConfigHotReloadPayload,
|
||||
ResolvedControllerConfig,
|
||||
} from '../types';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
formatMediaTimingReviewControllerHints,
|
||||
} from './handlers/media-timing-review-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from './controller-profile-config.js';
|
||||
import { dispatchConfiguredMpvCommand } from './utils/mpv-command-dispatch.js';
|
||||
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause.js';
|
||||
import { getDefaultControllerReviewBindings } from '../shared/controller-review';
|
||||
import {
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
@@ -339,7 +342,15 @@ const fieldGroupingControllerLayout = createControllerComboScreenLayout({
|
||||
kikuModal.handleControllerAction(action);
|
||||
},
|
||||
});
|
||||
const keyboardModeAutoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => ctx.state.autoPauseVideoOnKeyboardMode,
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
onKeyboardDrivenModeChanged: (enabled) => {
|
||||
void keyboardModeAutoPause.handleModeChanged(enabled);
|
||||
},
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
||||
@@ -427,6 +438,12 @@ function applyControllerSnapshot(snapshot: {
|
||||
}
|
||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||
// Alt+C opens in its own window that cannot see a pad until a press there; it asks main
|
||||
// which controller the overlay is reading so it can preview the right one.
|
||||
if (!ctx.platform.isModalLayer && snapshot.activeGamepadId !== reportedActiveControllerId) {
|
||||
reportedActiveControllerId = snapshot.activeGamepadId;
|
||||
window.electronAPI.reportActiveController?.(snapshot.activeGamepadId);
|
||||
}
|
||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||
controllerSelectModal.updateDevices();
|
||||
@@ -467,12 +484,33 @@ function applyControllerSnapshot(snapshot: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While Alt+C is open, its window reads the controller selected there instead of the saved
|
||||
* one, so Learn hears the pad being configured when several are connected.
|
||||
*/
|
||||
function withAltCSelectedController(config: ResolvedControllerConfig): ResolvedControllerConfig {
|
||||
const selectedId = ctx.state.controllerSelectModalOpen
|
||||
? ctx.state.controllerSelectGamepadId
|
||||
: null;
|
||||
return selectedId ? { ...config, preferredGamepadId: selectedId } : config;
|
||||
}
|
||||
|
||||
let reportedActiveControllerId: string | null | undefined;
|
||||
|
||||
function showControllerDisabledNotice(): void {
|
||||
controllerStatusIndicator.show(
|
||||
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
|
||||
);
|
||||
}
|
||||
|
||||
/** A paused video stays paused after the jump, as with the keyboard shortcut. */
|
||||
function seekSubtitleForController(delta: -1 | 1): void {
|
||||
void dispatchConfiguredMpvCommand(['sub-seek', delta], {
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||
});
|
||||
}
|
||||
|
||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||
@@ -492,54 +530,60 @@ function startControllerPolling(): void {
|
||||
const gamepadController = createGamepadController({
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getConfig: () =>
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
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,
|
||||
withAltCSelectedController(
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
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: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
},
|
||||
),
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document),
|
||||
getInteractionBlocked: () => isControllerInputBlocked(),
|
||||
@@ -557,6 +601,19 @@ function startControllerPolling(): void {
|
||||
quitMpv: () => {
|
||||
window.electronAPI.sendMpvCommand(['quit']);
|
||||
},
|
||||
// Same as Shift+H / Shift+L and Ctrl+Shift+H / Ctrl+Shift+L.
|
||||
previousSubtitle: () => {
|
||||
seekSubtitleForController(-1);
|
||||
},
|
||||
nextSubtitle: () => {
|
||||
seekSubtitleForController(1);
|
||||
},
|
||||
replaySubtitle: () => {
|
||||
void window.electronAPI.dispatchSessionAction('replayCurrentSubtitle');
|
||||
},
|
||||
playNextSubtitle: () => {
|
||||
void window.electronAPI.dispatchSessionAction('playNextSubtitle');
|
||||
},
|
||||
previousAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
||||
},
|
||||
@@ -801,6 +858,20 @@ function registerKeyboardCommandHandlers(): void {
|
||||
ctx.state.controllerSuspendedByModalWindow = suspended;
|
||||
});
|
||||
|
||||
window.electronAPI.onPlaybackPauseChanged((paused: boolean) => {
|
||||
keyboardModeAutoPause.handlePlaybackPauseChanged(paused);
|
||||
});
|
||||
|
||||
// Alt+C saves in its own window; pick up remaps and the button style without a restart.
|
||||
window.electronAPI.onControllerConfigChanged(() => {
|
||||
window.electronAPI
|
||||
.getControllerConfig()
|
||||
.then((config) => {
|
||||
ctx.state.controllerConfig = config;
|
||||
})
|
||||
.catch((error: unknown) => console.error('Could not reload controller config', error));
|
||||
});
|
||||
|
||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RendererState = {
|
||||
controllerDebugModalOpen: boolean;
|
||||
subtitleSidebarModalOpen: boolean;
|
||||
controllerDeviceSelectedIndex: number;
|
||||
/** While Alt+C is open, the controller selected in it; that window then reads this pad. */
|
||||
controllerSelectGamepadId: string | null;
|
||||
controllerConfig: ResolvedControllerConfig | null;
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
@@ -141,6 +143,7 @@ export type RendererState = {
|
||||
jlptN5Color: string;
|
||||
preserveSubtitleLineBreaks: boolean;
|
||||
autoPauseVideoOnSubtitleHover: boolean;
|
||||
autoPauseVideoOnKeyboardMode: boolean;
|
||||
autoPauseVideoOnYomitanPopup: boolean;
|
||||
primaryVisibleOnYomitanPopup: boolean;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
@@ -241,6 +244,7 @@ export function createRendererState(): RendererState {
|
||||
controllerDebugModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
controllerDeviceSelectedIndex: 0,
|
||||
controllerSelectGamepadId: null,
|
||||
controllerConfig: null,
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
@@ -282,6 +286,7 @@ export function createRendererState(): RendererState {
|
||||
jlptN5Color: '#8aadf4',
|
||||
preserveSubtitleLineBreaks: false,
|
||||
autoPauseVideoOnSubtitleHover: false,
|
||||
autoPauseVideoOnKeyboardMode: false,
|
||||
autoPauseVideoOnYomitanPopup: false,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
frequencyDictionaryEnabled: false,
|
||||
|
||||
+24
-1
@@ -2077,6 +2077,10 @@ body:focus-visible,
|
||||
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
|
||||
content: attr(data-controller-hint);
|
||||
text-transform: none;
|
||||
/* Buttons use the system UI font, where ○ □ △ are small; Japanese fonts draw them (and
|
||||
the ╳ used for the PlayStation cross) at full size, and SubMiner already expects one. */
|
||||
font-family:
|
||||
'M PLUS 1', 'Noto Sans CJK JP', 'Hiragino Sans', 'Yu Gothic', 'DejaVu Sans', sans-serif;
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
@@ -2085,7 +2089,6 @@ body:focus-visible,
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -3485,6 +3488,8 @@ iframe[id^='yomitan-popup'],
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.9);
|
||||
color: var(--ctp-text);
|
||||
/* Selects default to the system UI font, where the PlayStation ○ is small. */
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.controller-select-summary {
|
||||
@@ -3595,6 +3600,7 @@ iframe[id^='yomitan-popup'],
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-family: inherit; /* the overlay's Japanese-first fonts draw ○ □ △ at full size */
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -3630,13 +3636,30 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-overlay2);
|
||||
}
|
||||
|
||||
/* Before Alt+C sees a controller the rows are a preview: actions look unavailable. */
|
||||
.controller-config-locked .controller-config-reset-icon,
|
||||
.controller-config-locked .controller-config-edit-icon,
|
||||
.controller-config-locked .controller-config-group-action,
|
||||
.controller-config-locked .controller-config-edit-panel button {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controller-config-edit-panel {
|
||||
/* The list is a height-capped flex column: without this the panel (overflow hidden, so no
|
||||
minimum height) is squeezed to its border once the rows overflow. */
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
animation: configEditSlideIn 180ms ease-out;
|
||||
border-bottom: 1px solid rgba(138, 173, 244, 0.15);
|
||||
background: rgba(138, 173, 244, 0.04);
|
||||
}
|
||||
|
||||
/* Redrawn while it stays open (after Learn, Reset, …): no slide-in again. */
|
||||
.controller-config-edit-panel-kept {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes configEditSlideIn {
|
||||
from {
|
||||
max-height: 0;
|
||||
|
||||
@@ -964,6 +964,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
|
||||
ctx.state.autoPauseVideoOnKeyboardMode = style.autoPauseVideoOnKeyboardMode ?? false;
|
||||
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
|
||||
syncPrimaryVisibleOnYomitanPopupClass(ctx);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||
|
||||
@@ -155,6 +155,7 @@ export type RendererDom = {
|
||||
controllerSelectModal: HTMLDivElement;
|
||||
controllerSelectClose: HTMLButtonElement;
|
||||
controllerSelectPicker: HTMLSelectElement;
|
||||
controllerSelectFaceButtonStyle: HTMLSelectElement;
|
||||
controllerSelectSummary: HTMLDivElement;
|
||||
controllerSelectStatus: HTMLDivElement;
|
||||
controllerConfigList: HTMLDivElement;
|
||||
@@ -432,6 +433,9 @@ export function resolveRendererDom(): RendererDom {
|
||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||
controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
|
||||
controllerSelectFaceButtonStyle: getRequiredElement<HTMLSelectElement>(
|
||||
'controllerSelectFaceButtonStyle',
|
||||
),
|
||||
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
|
||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||
controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
controllerReviewInputSourceKey,
|
||||
formatControllerReviewBinding,
|
||||
formatControllerReviewInput,
|
||||
getControllerFaceButtonName,
|
||||
isControllerFaceButtonLabels,
|
||||
parseControllerReviewBinding,
|
||||
resolveControllerReviewInput,
|
||||
type ControllerReviewInputContext,
|
||||
@@ -148,3 +150,55 @@ test('Alt+C updates accept field grouping bindings, checked against that dialog'
|
||||
);
|
||||
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
|
||||
});
|
||||
|
||||
test('PlayStation pads print symbols at the Xbox positions', () => {
|
||||
const playstation = { ...standardXbox, faceButtonLabels: 'playstation' as const };
|
||||
const name = (input: Parameters<typeof formatControllerReviewInput>[0]) =>
|
||||
formatControllerReviewInput(input, playstation);
|
||||
|
||||
// Same physical buttons as Xbox: A is the bottom button, shown as ╳.
|
||||
assert.equal(
|
||||
controllerReviewInputSourceKey(
|
||||
resolveControllerReviewInput({ kind: 'face', letter: 'a' }, playstation),
|
||||
),
|
||||
'button:0',
|
||||
);
|
||||
assert.deepEqual(
|
||||
(['a', 'b', 'x', 'y'] as const).map((letter) => name({ kind: 'face', letter })),
|
||||
['╳', '○', '□', '△'],
|
||||
);
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 3 }), '△');
|
||||
assert.equal(
|
||||
formatControllerReviewBinding(
|
||||
{
|
||||
kind: 'combo',
|
||||
press: { kind: 'face', letter: 'a' },
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
playstation,
|
||||
),
|
||||
'L1 + ╳',
|
||||
);
|
||||
assert.equal(getControllerFaceButtonName(1, 'nintendo'), 'A');
|
||||
assert.equal(getControllerFaceButtonName(4, 'playstation'), null);
|
||||
assert.equal(isControllerFaceButtonLabels('playstation'), true);
|
||||
assert.equal(isControllerFaceButtonLabels('sega'), false);
|
||||
});
|
||||
|
||||
test('Alt+C updates accept a button style globally and per controller', () => {
|
||||
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
assert.deepEqual(
|
||||
plain(
|
||||
parseControllerConfigUpdate({
|
||||
faceButtonLabels: 'xbox',
|
||||
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
|
||||
}),
|
||||
),
|
||||
{ faceButtonLabels: 'xbox', profiles: { 'pad-1': { faceButtonLabels: 'playstation' } } },
|
||||
);
|
||||
assert.equal(parseControllerConfigUpdate({ faceButtonLabels: 'sega' }), null);
|
||||
assert.equal(
|
||||
parseControllerConfigUpdate({ profiles: { 'pad-1': { faceButtonLabels: 1 } } }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -246,15 +246,35 @@ export type ControllerReviewInputSource =
|
||||
|
||||
type FacePosition = 'south' | 'east' | 'west' | 'north';
|
||||
|
||||
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
|
||||
/**
|
||||
* Where each layout letter sits. Xbox pads put A at the bottom, Nintendo-style pads on the
|
||||
* right. PlayStation pads follow the Xbox layout, so A is ╳, B ○, X □ and Y △.
|
||||
*/
|
||||
const FACE_POSITIONS: Record<
|
||||
ControllerFaceButtonLabels,
|
||||
Record<ControllerFaceLetter, FacePosition>
|
||||
> = {
|
||||
xbox: { a: 'south', b: 'east', x: 'west', y: 'north' },
|
||||
nintendo: { a: 'east', b: 'south', x: 'north', y: 'west' },
|
||||
playstation: { a: 'south', b: 'east', x: 'west', y: 'north' },
|
||||
};
|
||||
|
||||
/** Every face button style, in the order Alt+C offers them. */
|
||||
export const CONTROLLER_FACE_BUTTON_STYLES: readonly {
|
||||
id: ControllerFaceButtonLabels;
|
||||
name: string;
|
||||
/** Tells the layouts apart, since 8BitDo and other pads come in more than one. */
|
||||
hint: string;
|
||||
}[] = [
|
||||
{ id: 'xbox', name: 'Xbox', hint: 'A at the bottom, B on the right' },
|
||||
{ id: 'nintendo', name: 'Nintendo', hint: 'B at the bottom, A on the right' },
|
||||
{ id: 'playstation', name: 'PlayStation', hint: '\u2573 at the bottom, \u25cb on the right' },
|
||||
];
|
||||
|
||||
export function isControllerFaceButtonLabels(value: unknown): value is ControllerFaceButtonLabels {
|
||||
return CONTROLLER_FACE_BUTTON_STYLES.some((style) => style.id === value);
|
||||
}
|
||||
|
||||
const POSITION_INDEX_KEYS = {
|
||||
south: 'buttonSouth',
|
||||
east: 'buttonEast',
|
||||
@@ -359,11 +379,6 @@ const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
|
||||
down: '▼',
|
||||
};
|
||||
|
||||
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
|
||||
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
|
||||
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
|
||||
};
|
||||
|
||||
const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
|
||||
0: 'south',
|
||||
1: 'east',
|
||||
@@ -371,6 +386,32 @@ const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
|
||||
3: 'north',
|
||||
};
|
||||
|
||||
/** What is printed at each position, per style. */
|
||||
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
|
||||
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
|
||||
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
|
||||
playstation: { south: '\u2573', east: '\u25cb', west: '\u25a1', north: '\u25b3' },
|
||||
};
|
||||
|
||||
/** The printed name of the face button at index 0-3 (south, east, west, north), if any. */
|
||||
export function getControllerFaceButtonName(
|
||||
buttonIndex: number,
|
||||
faceButtonLabels: ControllerFaceButtonLabels,
|
||||
): string | null {
|
||||
const position = FACE_POSITION_BY_INDEX[buttonIndex];
|
||||
if (!position) return null;
|
||||
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
|
||||
}
|
||||
|
||||
/** How a layout letter is printed on this style, e.g. `a` is `A` on Xbox and `╳` on PlayStation. */
|
||||
function formatFaceLetter(
|
||||
letter: ControllerFaceLetter,
|
||||
faceButtonLabels: ControllerFaceButtonLabels,
|
||||
): string {
|
||||
const positions = FACE_POSITIONS[faceButtonLabels] ?? FACE_POSITIONS.xbox;
|
||||
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[positions[letter]];
|
||||
}
|
||||
|
||||
const STANDARD_BUTTON_NAMES: Record<number, string> = {
|
||||
4: 'L1',
|
||||
5: 'R1',
|
||||
@@ -420,10 +461,8 @@ function formatSource(
|
||||
}
|
||||
const standard = isStandardMapping(context.mapping);
|
||||
if (source.kind === 'button') {
|
||||
const position = FACE_POSITION_BY_INDEX[source.buttonIndex];
|
||||
if (position) {
|
||||
return (LETTER_AT_POSITION[context.faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
|
||||
}
|
||||
const faceName = getControllerFaceButtonName(source.buttonIndex, context.faceButtonLabels);
|
||||
if (faceName) return faceName;
|
||||
const names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
|
||||
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
|
||||
}
|
||||
@@ -448,7 +487,7 @@ export function formatControllerReviewInput(
|
||||
options: { dpadPrefix?: boolean } = {},
|
||||
): string {
|
||||
// Letters are shown as printed, whatever position they resolve to.
|
||||
if (input.kind === 'face') return input.letter.toUpperCase();
|
||||
if (input.kind === 'face') return formatFaceLetter(input.letter, context.faceButtonLabels);
|
||||
return formatSource(
|
||||
resolveControllerReviewInput(input, context),
|
||||
context,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const IPC_CHANNELS = {
|
||||
statsNativeDialogOpened: 'stats:native-dialog-opened',
|
||||
statsNativeDialogClosed: 'stats:native-dialog-closed',
|
||||
saveControllerConfig: 'save-controller-config',
|
||||
reportActiveController: 'controller:report-active',
|
||||
mpvCommand: 'mpv-command',
|
||||
refreshKnownWords: 'anki:refresh-known-words',
|
||||
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
|
||||
@@ -72,6 +73,7 @@ export const IPC_CHANNELS = {
|
||||
getMarkWatchedKey: 'get-mark-watched-key',
|
||||
getOverlayNotificationPosition: 'get-overlay-notification-position',
|
||||
getControllerConfig: 'get-controller-config',
|
||||
getActiveControllerId: 'controller:get-active',
|
||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||
youtubePickerResolve: 'youtube:picker-resolve',
|
||||
@@ -159,6 +161,8 @@ export const IPC_CHANNELS = {
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
controllerSuspendedChanged: 'controller:suspended-changed',
|
||||
controllerConfigChanged: 'controller:config-changed',
|
||||
playbackPauseChanged: 'mpv:pause-changed',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
changelogOpen: 'changelog:open',
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
|
||||
import type { SubtitlePosition } from '../../types/subtitle';
|
||||
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
||||
import { isControllerFaceButtonLabels } from '../controller-review';
|
||||
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
@@ -221,6 +222,10 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
for (const key of discreteKeys) {
|
||||
if (value[key] === undefined) continue;
|
||||
@@ -273,6 +278,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
||||
}
|
||||
if (value.faceButtonLabels !== undefined) {
|
||||
if (!isControllerFaceButtonLabels(value.faceButtonLabels)) return null;
|
||||
update.faceButtonLabels = value.faceButtonLabels;
|
||||
}
|
||||
if (value.buttonIndices !== undefined) {
|
||||
const parsed = parseControllerButtonIndices(value.buttonIndices);
|
||||
if (!parsed) return null;
|
||||
@@ -297,6 +306,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
||||
if (typeof rawProfile.label !== 'string') return null;
|
||||
profile.label = rawProfile.label;
|
||||
}
|
||||
if (rawProfile.faceButtonLabels !== undefined) {
|
||||
if (!isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) return null;
|
||||
profile.faceButtonLabels = rawProfile.faceButtonLabels;
|
||||
}
|
||||
if (rawProfile.buttonIndices !== undefined) {
|
||||
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
|
||||
if (!parsed) return null;
|
||||
|
||||
+26
-2
@@ -176,8 +176,11 @@ export type ControllerButtonBinding =
|
||||
|
||||
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
||||
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
||||
/** Letters printed on the face buttons: Xbox-style (A at the bottom) or Nintendo-style (A on the right). */
|
||||
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo';
|
||||
/**
|
||||
* Face button style: Xbox (A at the bottom), Nintendo (A on the right) or PlayStation
|
||||
* (╳ at the bottom, laid out like Xbox). Decides button names and where letters land.
|
||||
*/
|
||||
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo' | 'playstation';
|
||||
export type ControllerAxisDirection = 'negative' | 'positive';
|
||||
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
|
||||
|
||||
@@ -239,6 +242,10 @@ export interface ControllerBindingsConfig {
|
||||
holdSecondarySub?: ControllerDiscreteBindingConfig;
|
||||
previousEntry?: ControllerDiscreteBindingConfig;
|
||||
nextEntry?: ControllerDiscreteBindingConfig;
|
||||
previousSubtitle?: ControllerDiscreteBindingConfig;
|
||||
nextSubtitle?: ControllerDiscreteBindingConfig;
|
||||
replaySubtitle?: ControllerDiscreteBindingConfig;
|
||||
playNextSubtitle?: ControllerDiscreteBindingConfig;
|
||||
leftStickHorizontal?: ControllerAxisBindingConfig;
|
||||
leftStickVertical?: ControllerAxisBindingConfig;
|
||||
rightStickHorizontal?: ControllerAxisBindingConfig;
|
||||
@@ -258,6 +265,10 @@ export interface ResolvedControllerBindingsConfig {
|
||||
holdSecondarySub?: ResolvedControllerDiscreteBinding;
|
||||
previousEntry?: ResolvedControllerDiscreteBinding;
|
||||
nextEntry?: ResolvedControllerDiscreteBinding;
|
||||
previousSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
nextSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
replaySubtitle?: ResolvedControllerDiscreteBinding;
|
||||
playNextSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
leftStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
leftStickVertical?: ResolvedControllerAxisBinding;
|
||||
rightStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
@@ -357,6 +368,8 @@ export type ResolvedControllerFieldGroupingBindings = Record<
|
||||
|
||||
export interface ControllerProfileConfig {
|
||||
label?: string;
|
||||
/** Overrides `controller.faceButtonLabels` for this controller; set from Alt+C. */
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
buttonIndices?: ControllerButtonIndicesConfig;
|
||||
bindings?: ControllerBindingsConfig;
|
||||
reviewBindings?: ControllerReviewBindingsConfig;
|
||||
@@ -365,6 +378,7 @@ export interface ControllerProfileConfig {
|
||||
|
||||
export interface ResolvedControllerProfileConfig {
|
||||
label: string;
|
||||
faceButtonLabels: ControllerFaceButtonLabels;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
reviewBindings: ResolvedControllerReviewBindings;
|
||||
@@ -398,6 +412,8 @@ export interface ControllerDeviceInfo {
|
||||
index: number;
|
||||
mapping: string;
|
||||
connected: boolean;
|
||||
/** Gamepad API timestamp of the pad's last input; the newest is the one being used. */
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface ControllerButtonSnapshot {
|
||||
@@ -593,6 +609,10 @@ export interface ElectronAPI {
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
|
||||
/** The overlay reports which controller it is reading (null when none). */
|
||||
reportActiveController: (gamepadId: string | null) => void;
|
||||
/** The controller the overlay last reported, for Alt+C before it sees a pad itself. */
|
||||
getActiveControllerId: () => Promise<string | null>;
|
||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
@@ -673,6 +693,10 @@ export interface ElectronAPI {
|
||||
) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onControllerSuspendedChanged: (callback: (suspended: boolean) => void) => void;
|
||||
/** Alt+C saved controller settings; reload them with `getControllerConfig`. */
|
||||
onControllerConfigChanged: (callback: () => void) => void;
|
||||
/** mpv's pause property changed, from any source. */
|
||||
onPlaybackPauseChanged: (callback: (paused: boolean) => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface SubtitleStyleConfig {
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
autoPauseVideoOnYomitanPopup?: boolean;
|
||||
autoPauseVideoOnKeyboardMode?: boolean;
|
||||
primaryVisibleOnYomitanPopup?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
hoverTokenBackgroundColor?: string;
|
||||
|
||||
Reference in new issue
Block a user