mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
5
Commits
79e5535823
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1865f4a6
|
||
|
|
6fd55053d4 | ||
|
|
0360270e81
|
||
|
|
a8e929eb26
|
||
|
|
b31ddce627 |
No files matched your search
@@ -32,6 +32,9 @@ jobs:
|
||||
- name: Verify native mpv process launch
|
||||
run: bun test src/main/runtime/mpv-process.test.ts
|
||||
|
||||
- name: Verify mpv PID lookup and Windows tracker
|
||||
run: bun test src/window-trackers/mpv-process-id.test.ts src/window-trackers/windows-tracker.test.ts
|
||||
|
||||
- name: Verify POSIX launcher bootstrap
|
||||
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
||||
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.22.0 (2026-11-06)
|
||||
|
||||
### Added
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||
- Plugin authors get a versioned API with standalone types.
|
||||
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||
- **Keyboard-Only Mode**:
|
||||
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||
|
||||
### Changed
|
||||
- **Stats Trends and Vocabulary**:
|
||||
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Stats now explains when streamed words or lines can't be mined.
|
||||
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||
|
||||
### Fixed
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||
- The window previews your bindings until a press, then selects the pressed controller.
|
||||
- Button names are now correct on standard-layout controllers.
|
||||
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||
- **Stats**:
|
||||
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||
- Date handling is now correct in time zones ahead of UTC.
|
||||
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||
|
||||
### Docs
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -104,7 +104,7 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Plugins</b></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. Bundled examples explain the current line with Codex, sync Anki to AnkiWeb, and tag mined cards by episode. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself.
|
||||
- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- `ankiConnect.fields.audio` now defaults to `SentenceAudio` instead of `ExpressionAudio`, so generated sentence audio no longer overwrites Yomitan's word audio on default configs. `fields.wordAudio` still defaults to `ExpressionAudio`. If you relied on the old default, set `fields.audio` to `ExpressionAudio` explicitly.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Fixed AppImage builds on Ubuntu 26.04+ (uutils coreutils) releasing their mount before Chromium child processes finished shutting down, which brought back "Service Crash" notifications on quit.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Controller remaps made in `Alt+C` now apply right away; before, the overlay kept the old bindings until SubMiner restarted.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Controller connected and controller-disabled messages are now regular notifications that follow `ankiConnect.behavior.notificationType` (overlay, system, both or none) instead of a separate pill in the corner of the overlay. Connected notices only appear when `controller.enabled` is on.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut while a controller is connected, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while a controller is connected, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
|
||||
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- The controller's **Toggle MPV Pause** button now works without keyboard-only mode, so you can pause while just watching.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
|
||||
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- With the Xbox **Button Style**, `Alt+C` names the face buttons `A`, `B`, `X` and `Y` instead of `A / Cross`, `B / Circle`, `X / Square` and `Y / Triangle`; PlayStation and Nintendo already showed their own names.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: overlay
|
||||
|
||||
- Listed the keyboard-only `C` key in Keyboard shortcuts, and pointed the configuration reference to Controller support for default button actions.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: dictionary
|
||||
|
||||
- Updated the bundled Hachidori to upstream 0.4.0 (d959a09), including every pitch accent in every popup entry, pitch drawn on the headword's reading, headword furigana split per kanji, and Yomitan-style definition tags.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: jimaku
|
||||
|
||||
- Added `jimaku.autoOpenWhenPrimarySubtitleMissing` (off by default). When a local video loads without a Japanese (primary-language) subtitle track, SubMiner pauses playback and opens the Jimaku modal; playback resumes when the modal closes.
|
||||
- Opening Jimaku while it is already open now reloads the title and episode if the video changed, instead of keeping the previous episode's search.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Keyboard-only mode can move by character instead of by word: press `C`, or the new **Toggle Character Navigation** controller binding (unbound by default). The cursor box turns orange and covers one character, and a lookup searches from it, which reaches words the subtitle split cuts in two.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Turning keyboard-only mode off, from the keyboard or a controller, now also closes the dictionary popup.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS, controller buttons now work while mpv is in front: a press brings the overlay forward and runs that button's action, instead of doing nothing until you click the overlay.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS and Windows, one controller press no longer runs its action twice, which cancelled out toggles like mpv pause.
|
||||
- On macOS, the press that brings the overlay forward from mpv no longer runs its action a second time.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: mpv-plugin
|
||||
|
||||
- Fixed the "Overlay loading" message staying on screen when mpv moves to the next file in a playlist, then turning into "Overlay did not become ready; check SubMiner logs" after 30 seconds even though the overlay was working. The overlay window is reused between files, so the plugin no longer restarts the loading message once the overlay has reported ready.
|
||||
@@ -1,9 +0,0 @@
|
||||
type: added
|
||||
area: plugins
|
||||
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||
- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends.
|
||||
- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`.
|
||||
- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins.
|
||||
- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Stats-dashboard cards do not trigger plugin events. Plugin subtitle lookups and mining provenance account for subtitle delay and return timing on the subtitle file's timeline.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed controller and keyboard lookups sometimes pausing playback without a popup, leaving playback paused after the popup closed until the mouse moved, or not pausing when the popup opened.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Reloading the same file in mpv (`loadfile <current path> replace`) could intermittently leave playback paused forever. SubMiner now treats that reload like any other newly loaded file, so it resets the subtitle state and resumes once the file is ready.
|
||||
@@ -1,8 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- The stats dashboard loads much faster on large databases. Overview, media and library detail, and trends no longer re-count every recorded word in the whole database on each request, so these views only cost as much as the sessions they show. On a ~1 GB database the overview went from ~1.4 s to ~0.14 s, media detail from ~1.5 s to ~0.08 s, the 30-day trends from ~1.5 s to ~0.06 s, and all-time trends from ~1.6 s to ~0.2 s.
|
||||
- The Vocabulary tab's summary and word list load several times faster (summary ~2 s to ~0.2 s, word list ~0.6 s to ~0.2 s).
|
||||
- The Trends tab downloads far less data: the per-title library charts now arrive as daily values and are totalled in the dashboard, so the all-time response shrank from ~3.9 MB to ~0.2 MB.
|
||||
- Sentence search with headword matching returns several times faster (~0.3 s to ~0.08 s per query).
|
||||
- Vocabulary totals and new-word charts stay correct after running vocabulary cleanup. Words it repaired could be counted or hidden by mistake; the first launch after updating corrects them in the background.
|
||||
@@ -1,15 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Rebuilt the Trends tab: summary totals with period comparison, a Learning section (new words per 1,000 seen, new words by frequency rank), a day and hour heatmap, a title timeline, and a single running-total chart with a metric switcher that climbs from zero. These replace the seven cumulative charts, the Sessions chart, the day-of-week and hour charts, the four overlapping per-title charts, and the top titles chart.
|
||||
- Added week grouping, custom date ranges, a title filter for the whole page, and click-through from charts to sessions and from titles to their library page. Range and grouping are remembered, and changing them no longer blanks the page or loses your scroll position.
|
||||
- Fixed the Library section including days outside the selected range, so its totals now match Activity.
|
||||
- Fixed episode counts: Trends now counts finished episodes, each once, instead of counting a video again for every day it was opened.
|
||||
- New word counts across Trends and Vocabulary now count dictionary words, so a word's conjugations count once. Totals are lower than before.
|
||||
- Rate charts no longer plot idle days as zero or spike on very short days: they show gaps when there is too little data, and daily views use a rolling 7 days.
|
||||
- Fixed clipped chart axis labels and month labels that read like dates, and the All range ignoring Show empty days.
|
||||
- Rebuilt the Vocabulary tab: one word table with Worth mining, All words, Across titles, and Newest views, search across every word, form, and reading, sortable columns, and inline Hide. It replaces three lists that could only see your 500 most seen word forms. Also added known words by frequency band, a vocabulary growth chart, and a kanji view covering every kanji, with a filter for kanji outside your known words.
|
||||
- Fixed word counts and readings: lists and the word detail panel now total every form of a word and show the dictionary form's reading, so 見る no longer shows みてる. The detail panel loads example lines straight away and lists a word's forms.
|
||||
- Fixed names slipping past Hide Names and the Trends new-word counts when tagged with more than one part of speech.
|
||||
- Stats now says when a word or line can't be mined because it came from a stream, in Search and the word panel, instead of showing Unavailable buttons. Lines you can mine are listed first. The word panel counts affected lines, and the word table marks words with only streamed lines as Stream only.
|
||||
- Fixed stats treating the day as one earlier in time zones ahead of UTC, which affected today's totals, the custom date picker, and first-seen dates.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Added subtitle filters (`subtitleFilter`) that remove unwanted text from primary subtitles before it reaches the overlay, sidebar, mined sentences and stats. Built-in presets cover text in parentheses (speaker names, readings, notes; whole-line inner thoughts are kept), square-bracket sound descriptions, speaker names before a colon, and music notes. Custom regex rules with a replacement can be added after the presets. Lines left with no words are hidden.
|
||||
- Configure them in the settings window under Behavior → Subtitle Filters; changes apply live to the current subtitle file.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: overlay
|
||||
|
||||
- Explain how to collect and interpret Windows overlay attachment and readiness warnings without enabling debug logging.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Windows overlay attachment now identifies mpv through its IPC process ID, removing the dependency on PowerShell command-line lookups and allowing the IPC socket to be configured in `mpv.conf`.
|
||||
- Revalidate cached Windows mpv process IDs and expire them after sustained IPC failures so PID reuse cannot keep the overlay attached to an unrelated window.
|
||||
- Diagnose stalled Windows overlays at the default warn log level, including tracking/PID state, renderer readiness, opacity, native binding errors, and recovery, with repeated warnings rate limited.
|
||||
+17
-22
@@ -128,62 +128,62 @@
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. 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 lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. 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 keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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. 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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleCharacterNavigation": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -194,11 +194,6 @@
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
|
||||
+58
-3
@@ -1,6 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
## v0.22.0 (2026-11-06)
|
||||
|
||||
**Added**
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||
- Plugin authors get a versioned API with standalone types.
|
||||
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||
- **Keyboard-Only Mode**:
|
||||
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||
|
||||
**Changed**
|
||||
- **Stats Trends and Vocabulary**:
|
||||
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Stats now explains when streamed words or lines can't be mined.
|
||||
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||
|
||||
**Fixed**
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||
- The window previews your bindings until a press, then selects the pressed controller.
|
||||
- Button names are now correct on standard-layout controllers.
|
||||
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||
- **Stats**:
|
||||
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||
- Date handling is now correct in time zones ahead of UTC.
|
||||
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||
|
||||
**Docs**
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.21.x</summary>
|
||||
|
||||
<h2>v0.21.1 (2026-10-26)</h2>
|
||||
|
||||
**Changed**
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
@@ -23,7 +78,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
<h2>v0.21.0 (2026-10-24)</h2>
|
||||
|
||||
**Added**
|
||||
- **Hachidori Dictionary Backend**:
|
||||
@@ -68,7 +123,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.20.x</summary>
|
||||
|
||||
@@ -329,7 +329,11 @@ Gamepad input for the overlay, through the browser Gamepad API. Most actions onl
|
||||
| `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.
|
||||
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller. Learn can also record a [combo](/usage#controller-support), saved on the binding as an optional `hold` input in the same form:
|
||||
|
||||
```jsonc
|
||||
"nextAudio": { "kind": "button", "buttonIndex": 15, "hold": { "kind": "button", "buttonIndex": 4 } }
|
||||
```
|
||||
|
||||
## Anki integration
|
||||
|
||||
|
||||
@@ -128,62 +128,62 @@
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. 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 lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. 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 keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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. 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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleCharacterNavigation": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -194,11 +194,6 @@
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
|
||||
@@ -51,6 +51,17 @@ When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMin
|
||||
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
|
||||
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
|
||||
- macOS: grant Accessibility permission. See [macOS](#macos).
|
||||
- Windows: leave the affected mpv window open for at least five seconds, then collect the daily
|
||||
app log from the directory above. No debug setting is required: a stalled overlay logs
|
||||
`Windows overlay is not visible after startup or tracking loss` with its PID, native window
|
||||
handle, tracking state, geometry, opacity, and renderer readiness. The `reason` distinguishes
|
||||
a missing mpv window, an unready renderer, a hidden window, and a transparent window.
|
||||
This warning waits until mpv has answered on its IPC socket, so a requested overlay with no
|
||||
mpv running stays quiet. PID lookup failures and native ownership or transparency errors also
|
||||
appear at `warn`, and a PID lookup failure is the only trace if mpv never answers.
|
||||
Repeated visibility/native-operation warnings are limited to one every 30 seconds; recovery
|
||||
is recorded too. These logs diagnose attachment and readiness, but cannot prove that pixels
|
||||
were drawn correctly by the graphics driver.
|
||||
|
||||
## Overlay is on the wrong monitor or position
|
||||
|
||||
|
||||
+3
-1
@@ -164,7 +164,9 @@ 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. 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.
|
||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want, or hold one button and press another to make a combo such as `L1` + D-pad right. 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.
|
||||
|
||||
A combo fires each time you press its second button while the first is down, so holding `L1` and pressing right twice runs it twice. While a combo's hold button is down, other buttons only run combos that use it, and its press no longer drives a **(D-pad)** or stick row. A hold button can keep its own action: that action then runs when you let go, unless you pressed another bound button during the hold. The exception is **Show Secondary Subtitle (Hold)**, which can't wait for release, so its row warns if it shares a hold button.
|
||||
|
||||
| Button | Action |
|
||||
| --------------------- | -------------------------------------- |
|
||||
|
||||
@@ -27,6 +27,16 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal
|
||||
open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
|
||||
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
|
||||
Windows window tracking: `src/window-trackers/windows-tracker.ts` queries the target mpv IPC
|
||||
socket for its PID through `mpv-process-id.ts`, then `win32.ts` matches native windows by PID.
|
||||
Visible matches are revalidated every 10 seconds; a PID expires after 30 seconds without a
|
||||
successful IPC response. Missing windows retry at one-second intervals. Custom executable
|
||||
names remain supported because matching uses the IPC PID.
|
||||
`src/core/services/overlay-visibility-diagnostics.ts` watches requested Windows overlays:
|
||||
after five seconds hidden or transparent it logs a warn-level snapshot, repeating at most
|
||||
every 30 seconds. Intentional hiding, minimization and having no mpv answer on the socket
|
||||
yet suppress these warnings; an identified mpv whose window never matches does not. PID lookup
|
||||
failures and native ownership/transparency failures also log at warn, including recovery.
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's
|
||||
startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state:
|
||||
|
||||
@@ -114,6 +114,45 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
// The other tests read the renderer over CDP, which works even if the window never appears.
|
||||
// Windows keeps the overlay hidden until the tracker identifies mpv's window.
|
||||
// Bun's node:test ignores the `skip` option, so this picks `test.skip` instead.
|
||||
const testOnWindows = process.platform === 'win32' ? test : test.skip;
|
||||
testOnWindows('visible overlay window appears over mpv', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||
const { session } = await useSession();
|
||||
await session.runAppCommand('--show-visible-overlay');
|
||||
const mpvPid = await session.mpv.command('get_property', 'pid');
|
||||
assert.equal(typeof mpvPid, 'number');
|
||||
const main = await session.mainProcess();
|
||||
await waitUntil(
|
||||
() =>
|
||||
main.evaluate<boolean>(
|
||||
`(() => {
|
||||
const electron = process.mainModule.require('electron');
|
||||
const overlay = electron.BrowserWindow.getAllWindows().find(
|
||||
(w) => w.webContents.getURL().includes(${JSON.stringify(OVERLAY_PAGE)}));
|
||||
if (!overlay?.isVisible() || overlay.getOpacity() !== 1 ||
|
||||
overlay.__subminerOverlayContentReady !== true) return false;
|
||||
// The inspector's mainModule can be Electron's bootstrap module. Resolve app
|
||||
// dependencies from the repository instead of that module's search paths.
|
||||
const appRequire = process.mainModule.require('node:module').createRequire(
|
||||
process.mainModule.require('node:path').resolve('package.json'));
|
||||
const koffi = appRequire('koffi');
|
||||
const getWindow = koffi.load('user32.dll').func(
|
||||
'intptr __stdcall GetWindow(intptr hwnd, uint uCmd)');
|
||||
const handle = overlay.getNativeWindowHandle();
|
||||
const hwnd = handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
|
||||
const owner = getWindow(hwnd, 4); // GW_OWNER
|
||||
const win32 = appRequire(
|
||||
process.mainModule.require('node:path').resolve('dist/window-trackers/win32.js'));
|
||||
return win32.findMpvWindows(${JSON.stringify(mpvPid)}).matches.some(
|
||||
(match) => match.hwnd === owner);
|
||||
})()`,
|
||||
),
|
||||
{ description: 'the visible overlay to be fully revealed and owned by the target mpv window' },
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -331,6 +370,35 @@ testOutsideLinux(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'controller combos survive the real save path and can be rebound to one button',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { overlay } = await useSession();
|
||||
const L1 = { kind: 'button', buttonIndex: 4 };
|
||||
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 };
|
||||
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 };
|
||||
const save = (bindings: Record<string, unknown>) =>
|
||||
overlay.evaluate<Record<string, unknown>>(
|
||||
`(async () => {
|
||||
await window.electronAPI.saveControllerConfig(${JSON.stringify({ bindings })});
|
||||
return (await window.electronAPI.getControllerConfig()).bindings;
|
||||
})()`,
|
||||
);
|
||||
|
||||
await save({ previousEntry: { ...DPAD_LEFT, hold: L1 } });
|
||||
const bothCombos = await save({ nextEntry: { ...DPAD_RIGHT, hold: L1 } });
|
||||
assert.deepEqual(bothCombos.previousEntry, { ...DPAD_LEFT, hold: L1 });
|
||||
assert.deepEqual(bothCombos.nextEntry, { ...DPAD_RIGHT, hold: L1 });
|
||||
|
||||
const rebound = await save({ previousEntry: DPAD_LEFT, nextEntry: { kind: 'none' } });
|
||||
assert.deepEqual(rebound.previousEntry, DPAD_LEFT);
|
||||
assert.deepEqual(rebound.nextEntry, { kind: 'none' });
|
||||
|
||||
await save({ previousEntry: { kind: 'none' } });
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
@@ -543,8 +543,10 @@ test('launchAppBackgroundDetached starts background child directly', async () =>
|
||||
|
||||
launchAppBackgroundDetached(appPath, 'info');
|
||||
|
||||
// The shell creates each file before writing to it, so wait for content, not existence.
|
||||
const hasContent = (file: string) => fs.existsSync(file) && fs.statSync(file).size > 0;
|
||||
const deadline = Date.now() + 1000;
|
||||
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
|
||||
while ((!hasContent(argsPath) || !hasContent(envPath)) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.1-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -1,88 +1,21 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
|
||||
|
||||
## Changes since v0.20.1-beta.1
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43.7.7.
|
||||
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
|
||||
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
|
||||
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
|
||||
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
|
||||
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
|
||||
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
|
||||
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
|
||||
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
|
||||
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
|
||||
<!-- prerelease-version: 0.22.1-beta.1 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **YouTube Browser Window**:
|
||||
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start.
|
||||
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
|
||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
|
||||
- **Mouse and Scroll Wheel Bindings**:
|
||||
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
|
||||
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
|
||||
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
|
||||
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release Assets**: Releases no longer include package-size JSON reports.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Jellyfin Subtitles**:
|
||||
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
|
||||
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
|
||||
- If one track fails to download, the other tracks are still selected.
|
||||
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
|
||||
- **Media Timing Review**:
|
||||
- Trim handles are now thin lines centered on the clip edges.
|
||||
- Original subtitle boundaries are now plain orange bars with no labels.
|
||||
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
|
||||
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
|
||||
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
|
||||
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
|
||||
- **Stats Accuracy**:
|
||||
- Sentence mining no longer counts each card twice in session and lifetime stats.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
|
||||
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
|
||||
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
|
||||
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
|
||||
- **Windows Overlay**:
|
||||
- The overlay now finds mpv more reliably, without relying on PowerShell lookups.
|
||||
- You can now configure the IPC socket in `mpv.conf`.
|
||||
- The overlay no longer stays attached to an unrelated window after mpv's process ID is reused.
|
||||
- Stalled overlays now log diagnostic warnings without debug logging enabled.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
|
||||
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
|
||||
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
|
||||
- **Windows Overlay Troubleshooting**: Added a guide to collecting and interpreting overlay attachment and readiness warnings without debug logging.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
- fix(overlay): identify Windows mpv windows through IPC PID by @ksyasuda in #330
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to mpv events and mined cards, call AnkiConnect, run programs, and show results in an overlay panel or window.
|
||||
- Three examples are included: `ai-explain` for line explanations, `anki-sync` for AnkiWeb syncing, and `episode-tags` for tagging cards by show and episode.
|
||||
- **Subtitle Filters**: Remove parenthesized text, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be fully operated by controller and show button hints. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Customization**:
|
||||
- `Alt+C` actions can be combos: click **Learn**, hold one button, and press another.
|
||||
- New unbound actions cover subtitle navigation, holding to show the secondary subtitle, Yomitan entry selection, and character navigation.
|
||||
- **Button Style** offers Xbox, Nintendo, or PlayStation names and symbols, saved per controller.
|
||||
- `Alt+C` now warns when two actions share an input.
|
||||
- **Keyboard-Only Mode**: Press `C` to move and look up by character, reaching words split by subtitle tokenization. **Pause Video In Keyboard-Only Mode** can also pause when the mode turns on and resume when it turns off.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Playback resumes when the modal closes.
|
||||
|
||||
### Changed
|
||||
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
||||
- **Stats Trends**: Rebuilt with period comparisons, Learning charts, a heatmap, a title timeline, and a single running-total chart. Week grouping, custom ranges, a page-wide title filter, and click-through to sessions and library pages are also new.
|
||||
- **Stats Vocabulary**:
|
||||
- Rebuilt around one searchable, sortable word table with Worth mining, All words, Across titles, and Newest views.
|
||||
- Added known words by frequency band, a vocabulary growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Words and lines from streams now say they can't be mined, instead of showing Unavailable buttons.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Controller connected and disabled messages now follow your regular notification setting instead of using a separate overlay pill.
|
||||
- **Keyboard-Only Mode Exit**: Turning the mode off now also closes the dictionary popup.
|
||||
- **Hachidori**: The bundled dictionary reader is updated to 0.4.0, adding every pitch accent in each popup entry, pitch drawn on the headword reading, per-kanji furigana, and Yomitan-style definition tags.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, without restarting SubMiner.
|
||||
- Editing stays locked until you press a button, so the window can't edit a controller it can't see.
|
||||
- With several controllers connected, it selects and listens to the one you just pressed.
|
||||
- **Learn**, **Clear**, and **Reset** are reachable again, D-pad **Learn** no longer errors, and button names are correct on standard-layout pads.
|
||||
- **Controller Input**: One press no longer runs its action twice on macOS and Windows. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Controller and keyboard lookups pause and resume playback reliably, and moving between words right after closing the popup no longer reopens it.
|
||||
- **Anki Sentence Audio**: `ankiConnect.fields.audio` now defaults to `SentenceAudio`, so generated audio no longer overwrites Yomitan's word audio. If you relied on the old default, set it to `ExpressionAudio`.
|
||||
- **AniList**: Progress sync now adds an exactly matched or pinned show to your Watching or Completed list when it isn't on your list yet. Completed, Paused, Dropped, and Rewatching entries are never changed.
|
||||
- **Stats Dashboard**:
|
||||
- Overview, media, library, trends, vocabulary, and sentence search load several times faster on large databases.
|
||||
- Trends totals, episode counts, rate charts, axis labels, and date handling in time zones ahead of UTC are corrected.
|
||||
- Word lists now total every form of a word and show the dictionary form's reading.
|
||||
- Names no longer slip past Hide Names, and totals stay correct after vocabulary cleanup.
|
||||
- **Playback**: Reloading the current file in mpv no longer leaves playback paused, and the "Overlay loading" message no longer gets stuck when moving to the next playlist file.
|
||||
- **AppImage**: "Service Crash" notifications on quit no longer appear on Ubuntu 26.04 and newer.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||
- feat(plugins): add JavaScript plugin system with overlay panel by @ksyasuda in #303
|
||||
- fix(anilist): add missing list entry on confident match by @dandascan in #305
|
||||
- Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names by @YashaNoos in #312
|
||||
- Controller: layouts for the media timing review and duplicate card dialogs by @YashaNoos in #313
|
||||
- Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes by @YashaNoos in #316
|
||||
- fix(plugin): keep loading OSD off on warm playlist advance by @ksyasuda in #317
|
||||
- feat(subtitles): add regex subtitle filters with built-in presets by @ksyasuda in #318
|
||||
- feat(jimaku): auto-open modal when a video has no Japanese subtitle by @dandascan in #319
|
||||
- fix(controller): pick up macOS presses while mpv is in front by @ksyasuda in #321
|
||||
- perf(stats): speed up dashboard queries on large databases by @ksyasuda in #322
|
||||
- fix(mpv): resume playback after a same-file reload that skips the path clear by @ksyasuda in #323
|
||||
- fix(controller): keep popup pause and press handling reliable by @ksyasuda in #324
|
||||
- feat(stats): overhaul the Trends tab by @ksyasuda in #325
|
||||
- Controller: character navigation in keyboard-only mode, plain Xbox face names by @YashaNoos in #326
|
||||
- feat(controller): add hold + press combos to main bindings by @ksyasuda in #328
|
||||
|
||||
## New Contributors
|
||||
|
||||
- @dandascan made their first contribution in #305
|
||||
- @YashaNoos made their first contribution in #312
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1297,7 +1297,6 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
|
||||
"toggleMpvPause": "leftStickPress",
|
||||
"leftStickHorizontal": "rightStickX",
|
||||
"leftStickVertical": "rightStickY",
|
||||
"rightStickHorizontal": "leftStickX",
|
||||
"rightStickVertical": "leftStickY"
|
||||
}
|
||||
}
|
||||
@@ -1383,6 +1382,59 @@ test('parses descriptor-based controller bindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses a hold input on discrete controller bindings and rejects an invalid one', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"bindings": {
|
||||
"nextAudio": {
|
||||
"kind": "button",
|
||||
"buttonIndex": 15,
|
||||
"hold": { "kind": "button", "buttonIndex": 4 }
|
||||
},
|
||||
"nextEntry": {
|
||||
"kind": "axis",
|
||||
"axisIndex": 0,
|
||||
"direction": "positive",
|
||||
"hold": { "kind": "axis", "axisIndex": 2, "direction": "positive" }
|
||||
},
|
||||
"mineCard": {
|
||||
"kind": "button",
|
||||
"buttonIndex": 12,
|
||||
"hold": { "kind": "button" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.controller.bindings.nextAudio, {
|
||||
kind: 'button',
|
||||
buttonIndex: 15,
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.nextEntry, {
|
||||
kind: 'axis',
|
||||
axisIndex: 0,
|
||||
direction: 'positive',
|
||||
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
});
|
||||
// A broken hold must not silently become a plain press of button 12.
|
||||
assert.deepEqual(
|
||||
config.controller.bindings.mineCard,
|
||||
DEFAULT_CONFIG.controller.bindings.mineCard,
|
||||
);
|
||||
assert.ok(
|
||||
service.getWarnings().some((warning) => warning.path === 'controller.bindings.mineCard'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -92,7 +92,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
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(),
|
||||
|
||||
@@ -125,11 +125,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||
description: 'Axis binding descriptor used for primary popup scrolling.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickHorizontal',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
||||
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickVertical',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||
@@ -472,7 +467,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: binding.defaultValue,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.`,
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.kind`,
|
||||
@@ -505,6 +500,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.hold`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: undefined,
|
||||
description:
|
||||
'Optional button or axis input, in the same form, that must be held down while pressing this one.',
|
||||
},
|
||||
]),
|
||||
...axisBindings.flatMap((binding) => [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
import {
|
||||
isControllerFaceButtonLabels,
|
||||
isControllerScreenAction,
|
||||
parseControllerDiscreteBinding,
|
||||
parseControllerReviewBinding,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
@@ -70,7 +70,6 @@ const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
@@ -111,7 +110,6 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
@@ -120,10 +118,6 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr
|
||||
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
@@ -152,29 +146,6 @@ function resolveLegacyAxisBinding(
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBindingObject(
|
||||
value: unknown,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
@@ -242,7 +213,7 @@ function applyControllerBindings(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
const parsedObject = parseControllerDiscreteBinding(bindingValue);
|
||||
if (parsedObject) {
|
||||
target[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
@@ -250,7 +221,7 @@ function applyControllerBindings(
|
||||
`${pathPrefix}.${key}`,
|
||||
bindingValue,
|
||||
target[key],
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ function createControllerConfigFixture() {
|
||||
dpadFallback: 'horizontal' as const,
|
||||
},
|
||||
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
|
||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createOverlayVisibilityDiagnostics,
|
||||
readOverlayVisibilitySnapshot,
|
||||
type OverlayVisibilitySnapshot,
|
||||
} from './overlay-visibility-diagnostics';
|
||||
|
||||
type SnapshotArgs = Parameters<typeof readOverlayVisibilitySnapshot>[0];
|
||||
|
||||
function snapshotFor(
|
||||
options: {
|
||||
tracking?: boolean;
|
||||
targetPid?: number | null;
|
||||
minimized?: boolean;
|
||||
window?: Record<string, unknown>;
|
||||
noTracker?: boolean;
|
||||
} & Pick<SnapshotArgs, 'modalActive' | 'suspendVisibleOverlay'> & {
|
||||
visibleOverlayVisible?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const tracker = {
|
||||
isTracking: () => options.tracking ?? false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => options.minimized ?? false,
|
||||
getDiagnosticState: () => ({ targetPid: options.targetPid ?? null, socketPath: '/tmp/mpv' }),
|
||||
};
|
||||
return readOverlayVisibilitySnapshot({
|
||||
mainWindow: {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
getOpacity: () => 0,
|
||||
...options.window,
|
||||
} as unknown as SnapshotArgs['mainWindow'],
|
||||
windowTracker: options.noTracker ? null : (tracker as unknown as SnapshotArgs['windowTracker']),
|
||||
visibleOverlayVisible: options.visibleOverlayVisible ?? true,
|
||||
modalActive: options.modalActive,
|
||||
suspendVisibleOverlay: options.suspendVisibleOverlay,
|
||||
});
|
||||
}
|
||||
|
||||
function harness() {
|
||||
let now = 0;
|
||||
let callback: (() => void) | null = null;
|
||||
let delay = 0;
|
||||
const warnings: Array<{ message: string; details: Record<string, unknown> }> = [];
|
||||
let snapshot: OverlayVisibilitySnapshot | null = {
|
||||
expectedVisible: true,
|
||||
visible: false,
|
||||
opacity: 0,
|
||||
contentReady: false,
|
||||
tracking: false,
|
||||
targetPid: 4242,
|
||||
pidLookupPending: false,
|
||||
};
|
||||
const diagnostics = createOverlayVisibilityDiagnostics({
|
||||
now: () => now,
|
||||
warn: (message, details) => warnings.push({ message, details }),
|
||||
schedule: (fn, ms) => {
|
||||
callback = fn;
|
||||
delay = ms;
|
||||
return { unref() {} } as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
cancel: () => {
|
||||
callback = null;
|
||||
},
|
||||
});
|
||||
const update = () => diagnostics.update(() => snapshot);
|
||||
return {
|
||||
warnings,
|
||||
update,
|
||||
setSnapshot: (value: typeof snapshot) => {
|
||||
snapshot = value;
|
||||
},
|
||||
tick: () => {
|
||||
assert.ok(callback, 'watchdog must be scheduled');
|
||||
now += delay;
|
||||
callback();
|
||||
},
|
||||
scheduled: () => callback !== null,
|
||||
};
|
||||
}
|
||||
|
||||
test('visibility watchdog warns without more events and rate limits repeated polls', () => {
|
||||
const h = harness();
|
||||
h.update();
|
||||
assert.equal(h.warnings.length, 0);
|
||||
for (let i = 0; i < 100; i += 1) h.update();
|
||||
h.tick();
|
||||
assert.equal(h.warnings.length, 1);
|
||||
assert.equal(h.warnings[0]?.details.reason, 'mpv-window-not-tracked');
|
||||
assert.equal(h.warnings[0]?.details.targetPid, 4242);
|
||||
assert.equal(h.warnings[0]?.details.elapsedMs, 5_000);
|
||||
for (let i = 0; i < 100; i += 1) h.update();
|
||||
assert.equal(h.warnings.length, 1);
|
||||
h.tick();
|
||||
assert.equal(h.warnings[1]?.details.elapsedMs, 35_000);
|
||||
});
|
||||
|
||||
test('visibility watchdog distinguishes renderer readiness, hidden windows and opacity', () => {
|
||||
for (const [contentReady, visible, opacity, reason] of [
|
||||
[false, false, 0, 'renderer-not-ready'],
|
||||
[true, false, 0, 'window-hidden'],
|
||||
[true, true, 0, 'window-transparent'],
|
||||
] as const) {
|
||||
const h = harness();
|
||||
h.setSnapshot({ expectedVisible: true, tracking: true, contentReady, visible, opacity });
|
||||
h.update();
|
||||
h.tick();
|
||||
assert.equal(h.warnings[0]?.details.reason, reason);
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility watchdog reports recovery only after warning and stops', () => {
|
||||
const h = harness();
|
||||
h.update();
|
||||
h.tick();
|
||||
h.setSnapshot({
|
||||
expectedVisible: true,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
contentReady: true,
|
||||
tracking: true,
|
||||
});
|
||||
h.tick();
|
||||
assert.match(h.warnings[1]!.message, /recovered/);
|
||||
assert.equal(h.scheduled(), false);
|
||||
h.update();
|
||||
assert.equal(h.warnings.length, 2);
|
||||
});
|
||||
|
||||
test('visibility watchdog stays quiet after fast startup, intentional hide or window destruction', () => {
|
||||
for (const snapshot of [
|
||||
{ expectedVisible: true, visible: true, opacity: 1, contentReady: true, tracking: true },
|
||||
{ expectedVisible: false, visible: false, opacity: 0, contentReady: false, tracking: false },
|
||||
null,
|
||||
]) {
|
||||
const h = harness();
|
||||
h.update();
|
||||
h.setSnapshot(snapshot);
|
||||
h.tick();
|
||||
assert.deepEqual(h.warnings, []);
|
||||
assert.equal(h.scheduled(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay snapshot expects visibility only once an mpv has been identified', () => {
|
||||
// Requested before any mpv exists: auto_start_overlay, tray or --show-visible-overlay.
|
||||
assert.equal(snapshotFor()?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ noTracker: true })?.expectedVisible, false);
|
||||
// mpv answered on the socket but its window never matched, so tracking never started.
|
||||
assert.equal(snapshotFor({ targetPid: 4242 })?.expectedVisible, true);
|
||||
// A tracker with no target socket matches windows without ever learning a PID.
|
||||
assert.equal(snapshotFor({ tracking: true })?.expectedVisible, true);
|
||||
});
|
||||
|
||||
test('overlay snapshot does not expect an intentionally hidden overlay', () => {
|
||||
const identified = { targetPid: 4242, tracking: true };
|
||||
assert.equal(snapshotFor(identified)?.expectedVisible, true);
|
||||
assert.equal(snapshotFor({ ...identified, modalActive: true })?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ ...identified, suspendVisibleOverlay: true })?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ ...identified, minimized: true })?.expectedVisible, false);
|
||||
assert.equal(
|
||||
snapshotFor({ ...identified, visibleOverlayVisible: false })?.expectedVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay snapshot carries tracker state, defaults opacity and ends with the window', () => {
|
||||
const snapshot = snapshotFor({ targetPid: 4242, window: { getOpacity: undefined } });
|
||||
assert.equal(snapshot?.targetPid, 4242);
|
||||
assert.equal(snapshot?.socketPath, '/tmp/mpv');
|
||||
assert.equal(snapshot?.opacity, 1);
|
||||
assert.equal(snapshotFor({ window: { isDestroyed: () => true } }), null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from './overlay-window-flags';
|
||||
|
||||
const log = createLogger('overlay:visibility');
|
||||
|
||||
export type OverlayVisibilitySnapshot = {
|
||||
expectedVisible: boolean;
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
contentReady: boolean;
|
||||
tracking: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// One watchdog per window, independent of further visibility events: a renderer
|
||||
// that never becomes ready must still leave evidence at the default warn level.
|
||||
export function createOverlayVisibilityDiagnostics(deps: {
|
||||
warn: (message: string, details: Record<string, unknown>) => void;
|
||||
now: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
cancel: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
}) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let readSnapshot: () => OverlayVisibilitySnapshot | null = () => null;
|
||||
let stalledAtMs: number | null = null;
|
||||
let warned = false;
|
||||
let lastWarningAtMs = 0;
|
||||
|
||||
const check = () => {
|
||||
if (timer !== null) deps.cancel(timer);
|
||||
timer = null;
|
||||
const snapshot = readSnapshot();
|
||||
if (!snapshot?.expectedVisible) {
|
||||
stalledAtMs = null;
|
||||
warned = false;
|
||||
return;
|
||||
}
|
||||
if (snapshot.visible && snapshot.opacity > 0 && snapshot.contentReady) {
|
||||
if (warned) {
|
||||
deps.warn('Windows overlay visibility recovered', {
|
||||
...snapshot,
|
||||
elapsedMs: deps.now() - stalledAtMs!,
|
||||
});
|
||||
}
|
||||
stalledAtMs = null;
|
||||
warned = false;
|
||||
return;
|
||||
}
|
||||
stalledAtMs ??= deps.now();
|
||||
const elapsedMs = deps.now() - stalledAtMs;
|
||||
if (elapsedMs >= 5_000 && (!warned || deps.now() - lastWarningAtMs >= 30_000)) {
|
||||
const reason = !snapshot.tracking
|
||||
? 'mpv-window-not-tracked'
|
||||
: !snapshot.contentReady
|
||||
? 'renderer-not-ready'
|
||||
: !snapshot.visible
|
||||
? 'window-hidden'
|
||||
: 'window-transparent';
|
||||
deps.warn('Windows overlay is not visible after startup or tracking loss', {
|
||||
...snapshot,
|
||||
reason,
|
||||
elapsedMs,
|
||||
});
|
||||
warned = true;
|
||||
lastWarningAtMs = deps.now();
|
||||
}
|
||||
timer = deps.schedule(
|
||||
check,
|
||||
warned ? 30_000 - (deps.now() - lastWarningAtMs) : 5_000 - elapsedMs,
|
||||
);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
return {
|
||||
update(reader: () => OverlayVisibilitySnapshot | null) {
|
||||
readSnapshot = reader;
|
||||
// Frequent polls must not postpone a warning or bypass its rate limit.
|
||||
check();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const diagnosticsByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
ReturnType<typeof createOverlayVisibilityDiagnostics>
|
||||
>();
|
||||
|
||||
type OverlayVisibilityWatchArgs = {
|
||||
mainWindow: BrowserWindow;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
suspendVisibleOverlay?: boolean;
|
||||
};
|
||||
|
||||
export function readOverlayVisibilitySnapshot(
|
||||
args: OverlayVisibilityWatchArgs,
|
||||
): OverlayVisibilitySnapshot | null {
|
||||
const window = args.mainWindow;
|
||||
if (window.isDestroyed()) return null;
|
||||
const tracker = args.windowTracker as
|
||||
| (BaseWindowTracker & {
|
||||
getDiagnosticState?: () => { targetPid?: number | null } & Record<string, unknown>;
|
||||
})
|
||||
| null;
|
||||
const trackerState = tracker?.getDiagnosticState?.();
|
||||
const flags = window as unknown as Record<string, unknown>;
|
||||
const minimized = tracker?.isTargetWindowMinimized?.() ?? false;
|
||||
const tracking = tracker?.isTracking() ?? false;
|
||||
// The overlay may be requested with no mpv to attach to (auto_start_overlay, tray,
|
||||
// --show-visible-overlay), so it only counts as stalled once an mpv has answered on the
|
||||
// socket. That still covers an mpv whose window never matches, which never starts tracking.
|
||||
const mpvIdentified = tracking || (trackerState?.targetPid ?? null) !== null;
|
||||
return {
|
||||
...trackerState,
|
||||
expectedVisible:
|
||||
args.visibleOverlayVisible &&
|
||||
mpvIdentified &&
|
||||
!args.modalActive &&
|
||||
!args.suspendVisibleOverlay &&
|
||||
!minimized,
|
||||
visible: window.isVisible(),
|
||||
opacity: window.getOpacity?.() ?? 1,
|
||||
contentReady: flags[OVERLAY_WINDOW_CONTENT_READY_FLAG] === true,
|
||||
documentLoaded: flags[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === true,
|
||||
rendererLoading: window.webContents?.isLoading?.() ?? null,
|
||||
tracking,
|
||||
trackerInitialized: tracker !== null,
|
||||
geometry: tracker?.getGeometry() ?? null,
|
||||
focused: tracker?.isTargetWindowFocused?.() ?? false,
|
||||
minimized,
|
||||
};
|
||||
}
|
||||
|
||||
export function watchWindowsOverlayVisibility(args: OverlayVisibilityWatchArgs): void {
|
||||
const window = args.mainWindow;
|
||||
let diagnostics = diagnosticsByWindow.get(window);
|
||||
if (!diagnostics) {
|
||||
diagnostics = createOverlayVisibilityDiagnostics({
|
||||
warn: (message, details) => log.warn(message, details),
|
||||
now: Date.now,
|
||||
schedule: setTimeout,
|
||||
cancel: clearTimeout,
|
||||
});
|
||||
diagnosticsByWindow.set(window, diagnostics);
|
||||
}
|
||||
diagnostics.update(() => readOverlayVisibilitySnapshot(args));
|
||||
}
|
||||
@@ -589,6 +589,34 @@ runScenarios([
|
||||
},
|
||||
]);
|
||||
|
||||
test('only a live Windows overlay reads visibility diagnostics', () => {
|
||||
const diagnosticReads = (platform: Platform, overrides: Partial<VisibilityArgs> = {}): number => {
|
||||
let reads = 0;
|
||||
createHarness({ platform, tracker: tracked() }).run({
|
||||
windowTracker: {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => GEOMETRY,
|
||||
getDiagnosticState: () => {
|
||||
reads += 1;
|
||||
return { targetPid: 4242 };
|
||||
},
|
||||
} as unknown as VisibilityArgs['windowTracker'],
|
||||
...overrides,
|
||||
});
|
||||
return reads;
|
||||
};
|
||||
|
||||
assert.equal(diagnosticReads('windows'), 1);
|
||||
assert.equal(diagnosticReads('linux'), 0);
|
||||
assert.equal(diagnosticReads('macos'), 0);
|
||||
assert.equal(
|
||||
diagnosticReads('windows', {
|
||||
mainWindow: { isDestroyed: () => true } as unknown as VisibilityArgs['mainWindow'],
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows visible overlay restores opacity and rebinds after the deferred reveal delay', async () => {
|
||||
const h = createHarness({ platform: 'windows', tracker: tracked() });
|
||||
assertOutcome(h.run(), { opacity: 0, zOrderSyncs: 1 });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
import { watchWindowsOverlayVisibility } from './overlay-visibility-diagnostics';
|
||||
|
||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||
@@ -65,7 +66,7 @@ function isOverlayWindowContentReady(window: BrowserWindow): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function updateVisibleOverlayVisibility(args: {
|
||||
function applyVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
@@ -453,6 +454,15 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
export function updateVisibleOverlayVisibility(
|
||||
args: Parameters<typeof applyVisibleOverlayVisibility>[0],
|
||||
): void {
|
||||
applyVisibleOverlayVisibility(args);
|
||||
if (args.isWindowsPlatform && args.mainWindow && !args.mainWindow.isDestroyed()) {
|
||||
watchWindowsOverlayVisibility({ ...args, mainWindow: args.mainWindow });
|
||||
}
|
||||
}
|
||||
|
||||
export function setVisibleOverlayVisible(options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
|
||||
+2
-5
@@ -29,7 +29,7 @@ import {
|
||||
dialog,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { saveControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { createHachidoriExtensionRuntime } from './core/services/hachidori-extension';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
@@ -1569,10 +1569,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
});
|
||||
},
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = configService.getRawConfig();
|
||||
configService.patchRawConfig({
|
||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
||||
});
|
||||
saveControllerConfigUpdate(configService, 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);
|
||||
|
||||
@@ -1,7 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
import { ConfigService } from '../config/service';
|
||||
import { parseControllerConfigUpdate } from '../shared/ipc/validators';
|
||||
import {
|
||||
applyControllerConfigUpdate,
|
||||
saveControllerConfigUpdate,
|
||||
} from './controller-config-update.js';
|
||||
|
||||
const L1 = { kind: 'button', buttonIndex: 4 } as const;
|
||||
const L2 = { kind: 'button', buttonIndex: 6 } as const;
|
||||
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 } as const;
|
||||
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 } as const;
|
||||
|
||||
/** One Alt+C save for a profile, through the same validator and merge as the real IPC handler. */
|
||||
function createSaveHarness() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-controller-save-'));
|
||||
const service = new ConfigService(dir);
|
||||
return {
|
||||
service,
|
||||
save: (action: string, binding: unknown) => {
|
||||
const update = parseControllerConfigUpdate({
|
||||
profiles: { 'pad-1': { bindings: { [action]: binding } } },
|
||||
});
|
||||
assert.ok(update, 'the validator accepts the update');
|
||||
saveControllerConfigUpdate(service, update);
|
||||
},
|
||||
savedBinding: (action: 'nextAudio' | 'previousAudio' | 'nextEntry') =>
|
||||
service.getConfig().controller.profiles['pad-1']?.bindings[action],
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test('combos sharing a hold button or a press button all survive later saves', () => {
|
||||
const harness = createSaveHarness();
|
||||
try {
|
||||
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
|
||||
harness.save('previousAudio', { ...DPAD_RIGHT, hold: L1 });
|
||||
harness.save('nextEntry', { ...DPAD_LEFT, hold: L2 });
|
||||
|
||||
assert.deepEqual(harness.savedBinding('nextAudio'), { ...DPAD_LEFT, hold: L1 });
|
||||
assert.deepEqual(harness.savedBinding('previousAudio'), { ...DPAD_RIGHT, hold: L1 });
|
||||
assert.deepEqual(harness.savedBinding('nextEntry'), { ...DPAD_LEFT, hold: L2 });
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('rebinding a combo to one button drops its hold', () => {
|
||||
const harness = createSaveHarness();
|
||||
try {
|
||||
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
|
||||
harness.save('nextAudio', DPAD_RIGHT);
|
||||
|
||||
assert.deepEqual(harness.savedBinding('nextAudio'), DPAD_RIGHT);
|
||||
assert.deepEqual(
|
||||
harness.service.getRawConfig().controller?.profiles?.['pad-1']?.bindings?.nextAudio,
|
||||
DPAD_RIGHT,
|
||||
);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('the validator rejects an invalid hold instead of saving a plain press', () => {
|
||||
assert.equal(
|
||||
parseControllerConfigUpdate({
|
||||
bindings: { nextAudio: { ...DPAD_LEFT, hold: { kind: 'face' } } },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ConfigService } from '../config/service';
|
||||
import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
@@ -96,6 +97,21 @@ function mergeControllerProfile(
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an Alt+C update to the config file. The controller section is replaced, not patched:
|
||||
* `patchRawConfig` deep-merges, so a combo rebound to one button would keep its old `hold`.
|
||||
*/
|
||||
export function saveControllerConfigUpdate(
|
||||
configService: Pick<ConfigService, 'getRawConfig' | 'saveRawConfig'>,
|
||||
update: ControllerConfigUpdate,
|
||||
): void {
|
||||
const rawConfig = configService.getRawConfig();
|
||||
configService.saveRawConfig({
|
||||
...rawConfig,
|
||||
controller: applyControllerConfigUpdate(rawConfig.controller, update),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyControllerConfigUpdate(
|
||||
currentController: RawConfig['controller'] | undefined,
|
||||
update: ControllerConfigUpdate,
|
||||
|
||||
@@ -21,45 +21,27 @@ function createSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
test('controller binding capture waits for neutral-to-active button edge', () => {
|
||||
test('controller binding capture waits for a neutral-to-active D-pad edge', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
const heldButtons = createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
const heldDpad = createSnapshot({
|
||||
buttons: buttons.map((button, index) =>
|
||||
index === 15 ? { value: 1, pressed: true, touched: true } : button,
|
||||
),
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
|
||||
capture.arm({ actionId: 'leftStickHorizontal', bindingType: 'dpad' }, heldDpad);
|
||||
|
||||
assert.equal(capture.poll(heldButtons), null);
|
||||
|
||||
const neutralButtons = createSnapshot();
|
||||
assert.equal(capture.poll(neutralButtons), null);
|
||||
|
||||
const freshPress = createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
});
|
||||
assert.deepEqual(capture.poll(freshPress), {
|
||||
actionId: 'toggleLookup',
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'button', buttonIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('controller binding capture records fresh axis direction for discrete learn mode', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'closeLookup', bindingType: 'discrete' }, createSnapshot());
|
||||
|
||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, -0.8] })), {
|
||||
actionId: 'closeLookup',
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'axis', axisIndex: 3, direction: 'negative' },
|
||||
assert.equal(capture.poll(heldDpad), null);
|
||||
assert.equal(capture.poll(createSnapshot({ buttons })), null);
|
||||
assert.deepEqual(capture.poll(heldDpad), {
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'dpad',
|
||||
dpadDirection: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +51,10 @@ test('controller binding capture ignores analog drift inside deadzone', () => {
|
||||
stickDeadzone: 0.3,
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
|
||||
capture.arm(
|
||||
{ actionId: 'leftStickHorizontal', bindingType: 'axis', dpadFallback: 'none' },
|
||||
createSnapshot(),
|
||||
);
|
||||
|
||||
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
|
||||
assert.equal(capture.isArmed(), true);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import type { ControllerDpadFallback, ResolvedControllerAxisBinding } from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -15,11 +11,8 @@ type ControllerBindingCaptureSnapshot = {
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
/** Learn for stick rows and their D-pad rows; discrete rows use the combo capture. */
|
||||
type ControllerBindingCaptureTarget =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
@@ -31,11 +24,6 @@ type ControllerBindingCaptureTarget =
|
||||
};
|
||||
|
||||
type ControllerBindingCaptureResult =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
binding: ResolvedControllerDiscreteBinding;
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
@@ -134,58 +122,24 @@ export function createControllerBindingCapture(options: {
|
||||
|
||||
const dpadDirection: ControllerDpadFallback =
|
||||
index === 12 || index === 13 ? 'vertical' : 'horizontal';
|
||||
const { actionId } = target;
|
||||
cancel();
|
||||
return {
|
||||
actionId: target.actionId,
|
||||
bindingType: 'dpad' as const,
|
||||
dpadDirection,
|
||||
};
|
||||
return { actionId, bindingType: 'dpad', dpadDirection };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// After dpad early-return, only 'discrete' | 'axis' remain
|
||||
const narrowedTarget: Extract<
|
||||
ControllerBindingCaptureTarget,
|
||||
{ bindingType: 'discrete' | 'axis' }
|
||||
> = target;
|
||||
|
||||
for (let index = 0; index < snapshot.buttons.length; index += 1) {
|
||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
||||
if (blockedButtons.has(index)) continue;
|
||||
if (narrowedTarget.bindingType === 'axis') continue;
|
||||
|
||||
const result: ControllerBindingCaptureResult = {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'button', buttonIndex: index },
|
||||
};
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
|
||||
for (let index = 0; index < snapshot.axes.length; index += 1) {
|
||||
const direction = getAxisDirection(snapshot.axes[index], activationThreshold);
|
||||
if (!direction) continue;
|
||||
const directionKey = `${index}:${direction}`;
|
||||
if (blockedAxisDirections.has(directionKey)) continue;
|
||||
|
||||
const result: ControllerBindingCaptureResult =
|
||||
narrowedTarget.bindingType === 'discrete'
|
||||
? {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'discrete',
|
||||
binding: { kind: 'axis', axisIndex: index, direction },
|
||||
}
|
||||
: {
|
||||
actionId: narrowedTarget.actionId,
|
||||
bindingType: 'axis',
|
||||
binding: {
|
||||
kind: 'axis',
|
||||
axisIndex: index,
|
||||
dpadFallback: narrowedTarget.dpadFallback,
|
||||
},
|
||||
};
|
||||
const result: ControllerBindingCaptureResult = {
|
||||
actionId: target.actionId,
|
||||
bindingType: 'axis',
|
||||
binding: { kind: 'axis', axisIndex: index, dpadFallback: target.dpadFallback },
|
||||
};
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -73,3 +73,49 @@ test('combo capture never records a trigger axis resting position', () => {
|
||||
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture records one axis from a diagonal push, not a hold and a press', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
const diagonal = snapshot([], [0.9, 0.9, 0, 0]);
|
||||
|
||||
const hold = { kind: 'axis', axisIndex: 0, direction: 'positive' } as const;
|
||||
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(snapshot()), {
|
||||
status: 'done',
|
||||
binding: { kind: 'combo', press: hold, hold: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture still records a second axis pressed after the first', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
|
||||
capture.poll(snapshot([], [0.9, 0, 0, 0]));
|
||||
assert.deepEqual(capture.poll(snapshot([], [0.9, 0.9, 0, 0])), {
|
||||
status: 'done',
|
||||
binding: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'axis', axisIndex: 1, direction: 'positive' },
|
||||
hold: { kind: 'axis', axisIndex: 0, direction: 'positive' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture records a button and an axis activated in the same poll as a combo', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
const both = snapshot([4], [0.9, 0, 0, 0]);
|
||||
|
||||
const hold = { kind: 'button', buttonIndex: 4 } as const;
|
||||
assert.deepEqual(capture.poll(both), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(both), {
|
||||
status: 'done',
|
||||
binding: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'axis', axisIndex: 0, direction: 'positive' },
|
||||
hold,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types';
|
||||
import type { ControllerRawInput } from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -13,19 +13,23 @@ type ControllerSnapshot = {
|
||||
|
||||
export type ControllerComboCaptureState =
|
||||
| { status: 'waiting' }
|
||||
| { status: 'holding'; hold: ControllerReviewInput }
|
||||
| { status: 'done'; binding: ResolvedControllerReviewBinding };
|
||||
| { status: 'holding'; hold: ControllerRawInput }
|
||||
| { status: 'done'; binding: ControllerCapturedCombo };
|
||||
|
||||
type RawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
|
||||
export type ControllerCapturedCombo = {
|
||||
kind: 'combo';
|
||||
press: ControllerRawInput;
|
||||
hold: ControllerRawInput | null;
|
||||
};
|
||||
|
||||
function inputKey(input: RawInput): string {
|
||||
function inputKey(input: ControllerRawInput): string {
|
||||
return input.kind === 'button'
|
||||
? `button:${input.buttonIndex}`
|
||||
: `axis:${input.axisIndex}:${input.direction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn for review bindings. Waits for a first input; letting go of it records it alone,
|
||||
* Learn for discrete and dialog bindings. Waits for a first input; letting go of it records it alone,
|
||||
* while pressing a second input during the hold records "hold first + press second".
|
||||
* Buttons already down when learning starts are ignored until released. Axis directions
|
||||
* active at the start stay ignored for the whole capture: some pads rest a trigger axis at
|
||||
@@ -37,10 +41,10 @@ export function createControllerComboCapture(options: {
|
||||
}) {
|
||||
const blocked = new Set<string>();
|
||||
const restingAxes = new Set<string>();
|
||||
let first: RawInput | null = null;
|
||||
let first: ControllerRawInput | null = null;
|
||||
|
||||
function activeInputs(snapshot: ControllerSnapshot): RawInput[] {
|
||||
const inputs: RawInput[] = [];
|
||||
function activeInputs(snapshot: ControllerSnapshot): ControllerRawInput[] {
|
||||
const inputs: ControllerRawInput[] = [];
|
||||
snapshot.buttons.forEach((button, buttonIndex) => {
|
||||
if (button && (Boolean(button.pressed) || button.value >= options.triggerDeadzone)) {
|
||||
inputs.push({ kind: 'button', buttonIndex });
|
||||
@@ -77,6 +81,11 @@ export function createControllerComboCapture(options: {
|
||||
const next = fresh[0];
|
||||
if (!next) return { status: 'waiting' };
|
||||
first = next;
|
||||
// Axes crossing the threshold in the same poll are one movement, such as a diagonal push.
|
||||
// Buttons sort first, so an axis here means every other fresh input is an axis too.
|
||||
if (next.kind === 'axis') {
|
||||
for (const input of fresh.slice(1)) blocked.add(inputKey(input));
|
||||
}
|
||||
return { status: 'holding', hold: first };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { controllerReviewInputSourceKey as inputKey } from '../../shared/controller-review';
|
||||
import type { ControllerRawInput, ResolvedControllerDiscreteBinding } from '../../types';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
|
||||
type BoundBinding = Exclude<ResolvedControllerDiscreteBinding, { kind: 'none' }>;
|
||||
|
||||
const isBound = (binding: ResolvedControllerDiscreteBinding): binding is BoundBinding =>
|
||||
binding.kind !== 'none';
|
||||
|
||||
/**
|
||||
* Per-poll view of hold + press combos in the main controller bindings. Dialog layouts have
|
||||
* their own matching in `controller-screen-layout.ts`; the rules for choosing a binding match.
|
||||
*
|
||||
* A press belongs to the binding that matches the hold buttons down when it starts, so
|
||||
* pressing the same input again during one hold fires the combo again, and letting go of the
|
||||
* hold first does not turn the press into its plain binding.
|
||||
*/
|
||||
export function createControllerComboTracker() {
|
||||
let pressedLastPoll: ReadonlySet<string> = new Set();
|
||||
let holdKeys: ReadonlySet<string> = new Set();
|
||||
let heldHoldKeys: string[] = [];
|
||||
let usedHolds: ReadonlySet<string> = new Set();
|
||||
/** Holds used during their press, kept for the poll that sees them released. */
|
||||
let cancelledTaps: ReadonlySet<string> = new Set();
|
||||
const claimedInputs = new Map<string, ControllerRawInput>();
|
||||
|
||||
function update(
|
||||
bindings: readonly ResolvedControllerDiscreteBinding[],
|
||||
isPressed: (input: ControllerRawInput) => boolean,
|
||||
): void {
|
||||
const bound = bindings.filter(isBound);
|
||||
const pressed = new Set<string>();
|
||||
for (const binding of bound) {
|
||||
for (const input of binding.hold ? [binding, binding.hold] : [binding]) {
|
||||
if (isPressed(input)) pressed.add(inputKey(input));
|
||||
}
|
||||
}
|
||||
|
||||
holdKeys = new Set(bound.flatMap((binding) => (binding.hold ? [inputKey(binding.hold)] : [])));
|
||||
heldHoldKeys = [...holdKeys].filter((key) => pressed.has(key));
|
||||
|
||||
const freshPresses = bound
|
||||
.map(inputKey)
|
||||
.filter((key) => pressed.has(key) && !pressedLastPoll.has(key));
|
||||
const nextUsedHolds = new Set(
|
||||
heldHoldKeys.filter(
|
||||
(holdKey) => usedHolds.has(holdKey) || freshPresses.some((key) => key !== holdKey),
|
||||
),
|
||||
);
|
||||
cancelledTaps = new Set([...usedHolds, ...nextUsedHolds]);
|
||||
usedHolds = nextUsedHolds;
|
||||
pressedLastPoll = pressed;
|
||||
|
||||
for (const key of claimedInputs.keys()) {
|
||||
if (!pressed.has(key)) claimedInputs.delete(key);
|
||||
}
|
||||
for (const binding of bound) {
|
||||
if (!binding.hold) continue;
|
||||
const pressKey = inputKey(binding);
|
||||
const holdKey = inputKey(binding.hold);
|
||||
if (pressKey !== holdKey && pressed.has(pressKey) && pressed.has(holdKey)) {
|
||||
claimedInputs.set(pressKey, binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
|
||||
reset(): void {
|
||||
pressedLastPoll = new Set();
|
||||
holdKeys = new Set();
|
||||
heldHoldKeys = [];
|
||||
usedHolds = new Set();
|
||||
cancelledTaps = new Set();
|
||||
claimedInputs.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a press of this binding counts right now: with one hold button down, only the
|
||||
* binding that uses it; with none, only a plain binding. Two holds down match nothing.
|
||||
*/
|
||||
matches(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
if (!isBound(binding)) return false;
|
||||
const pressKey = inputKey(binding);
|
||||
const others = heldHoldKeys.filter((key) => key !== pressKey);
|
||||
if (others.length > 1) return false;
|
||||
return others[0] === (binding.hold ? inputKey(binding.hold) : undefined);
|
||||
},
|
||||
|
||||
/** A plain binding on a hold button waits for release, when a tap can be told from a hold. */
|
||||
firesOnRelease(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
return isBound(binding) && !binding.hold && holdKeys.has(inputKey(binding));
|
||||
},
|
||||
|
||||
/** Another bound input was pressed during this hold, so its release is not a tap. */
|
||||
isTapCancelled(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
return isBound(binding) && cancelledTaps.has(inputKey(binding));
|
||||
},
|
||||
|
||||
/**
|
||||
* The pad with inputs claimed by a combo reading as released, for the stick actions and
|
||||
* their D-pad fallbacks. A claim lasts until the input is released, so letting go of the
|
||||
* hold first does not start moving the word selection.
|
||||
*/
|
||||
withoutClaimedInputs(gamepad: GamepadLike): GamepadLike {
|
||||
if (claimedInputs.size === 0) return gamepad;
|
||||
const axes = [...gamepad.axes];
|
||||
const buttons = [...gamepad.buttons];
|
||||
for (const input of claimedInputs.values()) {
|
||||
if (input.kind === 'button') {
|
||||
buttons[input.buttonIndex] = { value: 0, pressed: false, touched: false };
|
||||
} else if ((axes[input.axisIndex] ?? 0) > 0 === (input.direction === 'positive')) {
|
||||
axes[input.axisIndex] = 0;
|
||||
}
|
||||
}
|
||||
// Native Gamepad fields are prototype getters, so object spread would drop them.
|
||||
return {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: gamepad.connected,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: gamepad.timestamp,
|
||||
axes,
|
||||
buttons,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -919,3 +919,261 @@ test('gamepad controller pauses mpv outside keyboard-only mode, but not under a
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleMpvPause']);
|
||||
});
|
||||
|
||||
/** Harness driven frame by frame: `pads(4, 15)` polls once with those buttons down. */
|
||||
function createComboHarness(
|
||||
bindings: Partial<Bindings>,
|
||||
deps: Partial<ControllerOptions> = {},
|
||||
extraConfig: Partial<ResolvedControllerConfig> = {},
|
||||
) {
|
||||
let pressed: number[] = [];
|
||||
let now = 0;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
|
||||
}),
|
||||
],
|
||||
config: createControllerConfig({ ...extraConfig, bindings }),
|
||||
deps: { getLookupWindowOpen: () => true, ...deps },
|
||||
});
|
||||
return {
|
||||
...harness,
|
||||
pads: (...buttons: number[]) => {
|
||||
pressed = buttons;
|
||||
now += 16;
|
||||
harness.poll(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const L1 = 4;
|
||||
const R1 = 5;
|
||||
const DPAD_UP = 12;
|
||||
const DPAD_RIGHT = 15;
|
||||
const L1_DPAD_RIGHT = {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_RIGHT,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
} as const;
|
||||
|
||||
test('holding L1 and pressing D-pad right twice fires the combo twice and nothing else', () => {
|
||||
// L1 keeps its default Play Current Audio, and the D-pad keeps moving between words.
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextAudio']);
|
||||
});
|
||||
|
||||
test('a plain button that is also a hold button fires when tapped, on release', () => {
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio']);
|
||||
|
||||
// The next tap is unaffected by a combo used on an earlier hold.
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio', 'nextAudio', 'playCurrentAudio']);
|
||||
});
|
||||
|
||||
test('a plain button stays on press while no combo holds it', () => {
|
||||
const harness = createComboHarness({ nextAudio: { kind: 'button', buttonIndex: R1 } });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio']);
|
||||
});
|
||||
|
||||
test('a tap on a hold button also fires outside keyboard-only mode', () => {
|
||||
const harness = createComboHarness(
|
||||
{
|
||||
previousSubtitle: { kind: 'button', buttonIndex: L1 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: R1, hold: { kind: 'button', buttonIndex: L1 } },
|
||||
},
|
||||
{ getKeyboardModeEnabled: () => false },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['previousSubtitle']);
|
||||
});
|
||||
|
||||
test('a tap on a hold button is dropped if its action stops applying before the release', () => {
|
||||
let lookupOpen = true;
|
||||
const harness = createComboHarness(
|
||||
{ nextAudio: L1_DPAD_RIGHT },
|
||||
{ getLookupWindowOpen: () => lookupOpen },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
lookupOpen = false;
|
||||
harness.pads(L1);
|
||||
lookupOpen = true;
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('pressing a plain binding during a hold does nothing and cancels the tap', () => {
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, 2); // X is Mine Card; there is no L1 + X.
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('letting go of the hold first does not fire the plain binding on the same input', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
nextEntry: { kind: 'button', buttonIndex: DPAD_RIGHT },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(DPAD_RIGHT);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['nextAudio']);
|
||||
|
||||
harness.pads(DPAD_RIGHT);
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
});
|
||||
|
||||
test('a combo needs its hold down first, and two holds at once match nothing', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
previousAudio: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_RIGHT,
|
||||
hold: { kind: 'button', buttonIndex: R1 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(DPAD_RIGHT);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
harness.pads(L1, R1);
|
||||
harness.pads(L1, R1, DPAD_RIGHT);
|
||||
|
||||
// Only the first press of the D-pad moves a word; the combos never fire.
|
||||
assert.deepEqual(harness.calls, ['moveSelection:1']);
|
||||
});
|
||||
|
||||
test('a combo keeps the D-pad from driving its stick row until the D-pad is released', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
nextEntry: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_UP,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1, DPAD_UP);
|
||||
harness.pads(DPAD_UP);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
|
||||
// L1 with a direction no combo uses still moves between words.
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, 14);
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry', 'moveSelection:-1']);
|
||||
});
|
||||
|
||||
test('L1 and L2 can each hold the same press for different actions', () => {
|
||||
const L2 = 6;
|
||||
const DPAD_LEFT = 14;
|
||||
const harness = createComboHarness({
|
||||
// L2 keeps its default Quit MPV, so this also pins that a combo never quits on release.
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_LEFT,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
nextEntry: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_LEFT,
|
||||
hold: { kind: 'button', buttonIndex: L2 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_LEFT);
|
||||
harness.pads();
|
||||
harness.pads(L2);
|
||||
harness.pads(L2, DPAD_LEFT);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
});
|
||||
|
||||
test('the secondary subtitle hold can be a combo', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
holdSecondarySub: {
|
||||
kind: 'button',
|
||||
buttonIndex: R1,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
nextAudio: { kind: 'none' },
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(R1);
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, R1);
|
||||
harness.pads(L1);
|
||||
|
||||
assert.deepEqual(harness.secondarySubHeld, [true, false]);
|
||||
});
|
||||
|
||||
test('a dialog owning the pad keeps combos from firing and from leaving a tap behind', () => {
|
||||
let suspended = false;
|
||||
const harness = createComboHarness(
|
||||
{ nextAudio: L1_DPAD_RIGHT },
|
||||
{ getSuspended: () => suspended },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
suspended = true;
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
suspended = false;
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerComboTracker } from './controller-combo-tracker.js';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -228,6 +229,7 @@ function syncHeldActionBlocked(
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
/** Whether the binding's press input is down. A combo's `hold` is checked by the combo tracker. */
|
||||
export function resolveDiscreteBindingPressed(
|
||||
gamepad: GamepadLike,
|
||||
binding: ResolvedControllerDiscreteBinding,
|
||||
@@ -295,8 +297,24 @@ const WATCHING_ACTIONS = [
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
/** Every action fired by a fresh press; `holdSecondarySub` follows the button instead. */
|
||||
const PRESS_ACTIONS = [
|
||||
'toggleKeyboardOnlyMode',
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'toggleCharacterNavigation',
|
||||
...POPUP_ONLY_ACTIONS,
|
||||
...WATCHING_ACTIONS,
|
||||
] as const;
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousActions = new Map<string, boolean>();
|
||||
const comboTracker = createControllerComboTracker();
|
||||
/** Plain actions on a hold button, pressed and waiting to see whether they were a tap. */
|
||||
const pendingTaps = new Set<string>();
|
||||
const handledThisPoll = new Set<string>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
@@ -361,14 +379,38 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
config: ResolvedControllerConfig,
|
||||
action: () => void,
|
||||
): void {
|
||||
handledThisPoll.add(actionKey);
|
||||
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
|
||||
const wasPressed = previousActions.get(actionKey) ?? false;
|
||||
previousActions.set(actionKey, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
if (isPressed === wasPressed) return;
|
||||
|
||||
if (!isPressed) {
|
||||
if (pendingTaps.delete(actionKey) && !comboTracker.isTapCancelled(binding)) action();
|
||||
return;
|
||||
}
|
||||
if (!comboTracker.matches(binding)) return;
|
||||
if (comboTracker.firesOnRelease(binding)) {
|
||||
pendingTaps.add(actionKey);
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
// The action does not apply this poll. Its edge state keeps following the pad, so a release
|
||||
// is not missed and a held button waits for a fresh press, and a pending tap is dropped.
|
||||
function trackActionWithoutFiring(
|
||||
actionKey: (typeof PRESS_ACTIONS)[number],
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
previousActions.set(
|
||||
actionKey,
|
||||
resolveDiscreteBindingPressed(activeGamepad, config.bindings[actionKey], config),
|
||||
);
|
||||
if (!handledThisPoll.has(actionKey)) pendingTaps.delete(actionKey);
|
||||
}
|
||||
|
||||
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
@@ -410,28 +452,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
config: ResolvedControllerConfig,
|
||||
now: number,
|
||||
): void {
|
||||
const discreteActions = [
|
||||
['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
|
||||
['toggleLookup', config.bindings.toggleLookup],
|
||||
['closeLookup', config.bindings.closeLookup],
|
||||
['mineCard', config.bindings.mineCard],
|
||||
['quitMpv', config.bindings.quitMpv],
|
||||
['previousAudio', config.bindings.previousAudio],
|
||||
['nextAudio', config.bindings.nextAudio],
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
|
||||
for (const actionKey of PRESS_ACTIONS) {
|
||||
trackActionWithoutFiring(actionKey, activeGamepad, config);
|
||||
}
|
||||
|
||||
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
const selectionValue = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
config.bindings.leftStickHorizontal,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -442,7 +470,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
config.bindings.rightStickVertical,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -463,6 +491,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
const previousActiveGamepadId = activeGamepadId;
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
handledThisPoll.clear();
|
||||
|
||||
if (!activeGamepad) {
|
||||
syncSecondarySubHold(false);
|
||||
@@ -472,6 +501,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
// the press that reveals it started after the screen opened, so it should count.
|
||||
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
|
||||
previousActions = new Map();
|
||||
comboTracker.reset();
|
||||
pendingTaps.clear();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
@@ -481,11 +512,17 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
if (activeGamepad.id !== previousActiveGamepadId) {
|
||||
activeScreenLayout = null;
|
||||
previousActions = new Map();
|
||||
comboTracker.reset();
|
||||
pendingTaps.clear();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||
comboTracker.update(
|
||||
[...PRESS_ACTIONS, 'holdSecondarySub' as const].map((key) => activeConfig.bindings[key]),
|
||||
(input) => resolveDiscreteBindingPressed(activeGamepad, input, activeConfig),
|
||||
);
|
||||
|
||||
// A screen layout or another window owning the controller pauses every normal action,
|
||||
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
|
||||
@@ -521,7 +558,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
activeGamepad,
|
||||
activeConfig.bindings.holdSecondarySub,
|
||||
activeConfig,
|
||||
),
|
||||
) &&
|
||||
comboTracker.matches(activeConfig.bindings.holdSecondarySub),
|
||||
);
|
||||
|
||||
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
||||
@@ -592,6 +630,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
|
||||
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleActionEdge(
|
||||
@@ -631,7 +670,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
|
||||
const primaryScroll = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.leftStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activeConfig.stickDeadzone,
|
||||
@@ -644,7 +683,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.rightStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -654,24 +693,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
|
||||
// actions do: a release is not missed, and a button held as the popup opens waits for
|
||||
// a fresh press instead of firing at once.
|
||||
for (const actionKey of POPUP_ONLY_ACTIONS) {
|
||||
previousActions.set(
|
||||
actionKey,
|
||||
resolveDiscreteBindingPressed(
|
||||
activeGamepad,
|
||||
activeConfig.bindings[actionKey],
|
||||
activeConfig,
|
||||
),
|
||||
);
|
||||
trackActionWithoutFiring(actionKey, activeGamepad, activeConfig);
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.leftStickHorizontal,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
|
||||
@@ -39,6 +39,44 @@ test('a button on the D-pad clashes with a D-pad row that is still on', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const L1_DPAD_RIGHT = {
|
||||
kind: 'button',
|
||||
buttonIndex: 15,
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
} as const;
|
||||
|
||||
test('a combo does not clash with its hold or press used elsewhere on their own', () => {
|
||||
// L1 is Play Current Audio by default; the D-pad is still on Token Move.
|
||||
assert.deepEqual(issuesWith({ nextAudio: L1_DPAD_RIGHT }), {});
|
||||
});
|
||||
|
||||
test('two actions on the same combo both warn', () => {
|
||||
assert.deepEqual(issuesWith({ nextAudio: L1_DPAD_RIGHT, nextEntry: L1_DPAD_RIGHT }), {
|
||||
nextAudio: 'Same as Next Entry',
|
||||
nextEntry: 'Same as Next Audio',
|
||||
});
|
||||
});
|
||||
|
||||
test('a combo holding and pressing the same button warns', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
nextEntry: { kind: 'button', buttonIndex: 7, hold: { kind: 'button', buttonIndex: 7 } },
|
||||
}),
|
||||
{ nextEntry: 'Hold and press are the same button' },
|
||||
);
|
||||
});
|
||||
|
||||
test('the secondary subtitle hold warns on a hold button, since it cannot wait for release', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
holdSecondarySub: { kind: 'button', buttonIndex: 4 },
|
||||
}),
|
||||
{ holdSecondarySub: 'Also the hold button of Next Audio' },
|
||||
);
|
||||
});
|
||||
|
||||
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' } }),
|
||||
@@ -80,14 +118,6 @@ test('two stick actions on the same stick both warn', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { controllerReviewInputSourceKey } from '../../shared/controller-review';
|
||||
import { DPAD_AXIS_INDEX, DPAD_BUTTON_INDEX } from '../handlers/gamepad-controller.js';
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
@@ -16,12 +17,6 @@ export type ControllerBindingIssueDefinition = {
|
||||
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`;
|
||||
@@ -31,10 +26,14 @@ const buttonKey = (buttonIndex: number) => `button:${buttonIndex}`;
|
||||
const axisKey = (axisIndex: number, direction: 'negative' | 'positive') =>
|
||||
`axis:${axisIndex}:${direction}`;
|
||||
|
||||
/**
|
||||
* A combo counts as one input, apart from its hold and press alone: a plain action on the hold
|
||||
* button waits for release, and a combo keeps its press away from plain actions and stick rows.
|
||||
*/
|
||||
function discreteInputs(binding: ResolvedControllerDiscreteBinding): string[] {
|
||||
if (binding.kind === 'none') return [];
|
||||
if ('buttonIndex' in binding) return [buttonKey(binding.buttonIndex)];
|
||||
return [axisKey(binding.axisIndex, binding.direction)];
|
||||
const press = controllerReviewInputSourceKey(binding);
|
||||
return [binding.hold ? `${controllerReviewInputSourceKey(binding.hold)}+${press}` : press];
|
||||
}
|
||||
|
||||
function stickInputs(binding: ResolvedControllerAxisBinding): string[] {
|
||||
@@ -74,9 +73,10 @@ export function findControllerBindingIssues(
|
||||
definitions: readonly ControllerBindingIssueDefinition[],
|
||||
bindings: ControllerBindings,
|
||||
): Partial<Record<string, string>> {
|
||||
const issues: Partial<Record<string, string>> = {};
|
||||
const holdActions = new Map<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') {
|
||||
@@ -92,15 +92,30 @@ export function findControllerBindingIssues(
|
||||
inputs: dpadInputs(axisBinding.kind === 'none' ? 'none' : axisBinding.dpadFallback),
|
||||
});
|
||||
} else {
|
||||
const discreteBinding = binding as ResolvedControllerDiscreteBinding;
|
||||
rows.push({
|
||||
key: definition.id,
|
||||
label: definition.label,
|
||||
inputs: discreteInputs(binding as ResolvedControllerDiscreteBinding),
|
||||
inputs: discreteInputs(discreteBinding),
|
||||
});
|
||||
if (discreteBinding.kind !== 'none' && discreteBinding.hold) {
|
||||
const holdKey = controllerReviewInputSourceKey(discreteBinding.hold);
|
||||
if (holdKey === controllerReviewInputSourceKey(discreteBinding)) {
|
||||
issues[definition.id] = 'Hold and press are the same button';
|
||||
} else if (!holdActions.has(holdKey)) {
|
||||
holdActions.set(holdKey, definition.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const issues: Partial<Record<string, string>> = {};
|
||||
// Everything else on a hold button can wait for release. This one shows while held.
|
||||
const secondaryHold = bindings.holdSecondarySub;
|
||||
if (secondaryHold.kind !== 'none' && !secondaryHold.hold) {
|
||||
const comboLabel = holdActions.get(controllerReviewInputSourceKey(secondaryHold));
|
||||
if (comboLabel) issues.holdSecondarySub = `Also the hold button of ${comboLabel}`;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const inputs = new Set(row.inputs);
|
||||
const clashes = rows
|
||||
|
||||
@@ -31,7 +31,6 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
|
||||
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' },
|
||||
};
|
||||
|
||||
@@ -188,15 +187,13 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
['Token Move (D-pad)', 'D-pad ↔'],
|
||||
['Popup Scroll (Stick)', 'Left Stick Y'],
|
||||
['Popup Scroll (D-pad)', 'D-pad ↕'],
|
||||
['Alt Horizontal (Stick)', 'Right Stick X'],
|
||||
['Alt Horizontal (D-pad)', 'None'],
|
||||
['Popup Jump (Stick)', 'Right Stick Y'],
|
||||
['Popup Jump (D-pad)', 'None'],
|
||||
],
|
||||
);
|
||||
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', 'None', 'None', 'None', 'None', 'None'],
|
||||
);
|
||||
assert.deepEqual(findAll(container, 'expanded'), []);
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
@@ -216,7 +213,7 @@ test('controller config form expands the learning row with a listening edit pane
|
||||
assert.equal(panel.classList.contains('controller-config-edit-panel'), true);
|
||||
|
||||
const hint = findOne(panel, 'controller-config-edit-hint');
|
||||
assert.equal(hint.textContent, 'Press a button, trigger, or move a stick…');
|
||||
assert.equal(hint.textContent, 'Press a button, or hold one and press another for a combo…');
|
||||
assert.equal(hint.classList.contains('learning'), true);
|
||||
assert.equal(findOne(panel, 'btn-learn').textContent, 'Listening…');
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ControllerScreenLearning = {
|
||||
};
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ControllerRawInput,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
@@ -177,13 +178,6 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
},
|
||||
{
|
||||
id: 'rightStickHorizontal',
|
||||
label: 'Alt Horizontal',
|
||||
group: 'Navigation',
|
||||
bindingType: 'axis',
|
||||
defaultBinding: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'rightStickVertical',
|
||||
label: 'Popup Jump',
|
||||
@@ -296,22 +290,25 @@ export function getFriendlyAxisName(axisIndex: number, mapping?: ControllerMappi
|
||||
return names[axisIndex] ?? `Axis ${axisIndex}`;
|
||||
}
|
||||
|
||||
function formatInputSummary(input: ControllerRawInput): string {
|
||||
return input.kind === 'button'
|
||||
? `Button ${input.buttonIndex}`
|
||||
: `Axis ${input.axisIndex} ${input.direction === 'positive' ? '+' : '-'}`;
|
||||
}
|
||||
|
||||
export function formatControllerBindingSummary(
|
||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
||||
): string {
|
||||
if (binding.kind === 'none') {
|
||||
return 'Disabled';
|
||||
}
|
||||
if ('direction' in binding) {
|
||||
return `Axis ${binding.axisIndex} ${binding.direction === 'positive' ? '+' : '-'}`;
|
||||
if ('dpadFallback' in binding) {
|
||||
return binding.dpadFallback === 'none'
|
||||
? `Axis ${binding.axisIndex}`
|
||||
: `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
|
||||
}
|
||||
if ('buttonIndex' in binding) {
|
||||
return `Button ${binding.buttonIndex}`;
|
||||
}
|
||||
if (binding.dpadFallback === 'none') {
|
||||
return `Axis ${binding.axisIndex}`;
|
||||
}
|
||||
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
|
||||
const press = formatInputSummary(binding);
|
||||
return binding.hold ? `${formatInputSummary(binding.hold)} + ${press}` : press;
|
||||
}
|
||||
|
||||
function formatFriendlyStickLabel(
|
||||
@@ -322,20 +319,36 @@ function formatFriendlyStickLabel(
|
||||
return getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
}
|
||||
|
||||
export function formatFriendlyInputLabel(
|
||||
input: ControllerRawInput,
|
||||
mapping: ControllerMapping,
|
||||
faceButtonLabels: FaceButtonLabels,
|
||||
): string {
|
||||
if (input.kind === 'button') {
|
||||
return getFriendlyButtonName(input.buttonIndex, mapping, faceButtonLabels);
|
||||
}
|
||||
const name = getFriendlyAxisName(input.axisIndex, mapping);
|
||||
return `${name} ${input.direction === 'positive' ? '+' : '\u2212'}`;
|
||||
}
|
||||
|
||||
function formatFriendlyBindingLabel(
|
||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
||||
mapping: ControllerMapping,
|
||||
faceButtonLabels: FaceButtonLabels,
|
||||
): string {
|
||||
if (binding.kind === 'none') return 'None';
|
||||
if ('direction' in binding) {
|
||||
const name = getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
|
||||
}
|
||||
if ('buttonIndex' in binding) {
|
||||
return getFriendlyButtonName(binding.buttonIndex, mapping, faceButtonLabels);
|
||||
}
|
||||
return getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
if ('dpadFallback' in binding) return getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
const press = formatFriendlyInputLabel(binding, mapping, faceButtonLabels);
|
||||
return binding.hold
|
||||
? `${formatFriendlyInputLabel(binding.hold, mapping, faceButtonLabels)} + ${press}`
|
||||
: press;
|
||||
}
|
||||
|
||||
/** What Alt+C asks for while learning a binding that can be a combo. */
|
||||
function formatComboLearnHint(holdLabel: string | null): string {
|
||||
return 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';
|
||||
}
|
||||
|
||||
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
|
||||
@@ -345,6 +358,8 @@ export function createControllerConfigForm(options: {
|
||||
container: HTMLElement;
|
||||
getBindings: () => ResolvedControllerConfig['bindings'];
|
||||
getLearningActionId: () => ControllerBindingActionId | null;
|
||||
/** The hold button pressed so far while learning a discrete action, if any. */
|
||||
getLearningHoldLabel?: () => string | null;
|
||||
getDpadLearningActionId: () => ControllerBindingActionId | null;
|
||||
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
|
||||
onClear: (actionId: ControllerBindingActionId) => void;
|
||||
@@ -539,11 +554,7 @@ export function createControllerConfigForm(options: {
|
||||
options.container.appendChild(row);
|
||||
|
||||
if (isExpanded) {
|
||||
const hint = !isLearning
|
||||
? `Currently: ${current}`
|
||||
: 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 hint = isLearning ? formatComboLearnHint(holdLabel) : `Currently: ${current}`;
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: learn,
|
||||
onClear: (e) => {
|
||||
@@ -565,14 +576,17 @@ export function createControllerConfigForm(options: {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = learningActionId === definition.id;
|
||||
const holdLabel = isLearning ? (options.getLearningHoldLabel?.() ?? null) : null;
|
||||
|
||||
const row = createRow(
|
||||
definition.label,
|
||||
formatFriendlyBindingLabel(
|
||||
binding,
|
||||
options.getControllerMapping?.(),
|
||||
options.getFaceButtonLabels?.(),
|
||||
),
|
||||
holdLabel
|
||||
? `${holdLabel} + \u2026`
|
||||
: formatFriendlyBindingLabel(
|
||||
binding,
|
||||
options.getControllerMapping?.(),
|
||||
options.getFaceButtonLabels?.(),
|
||||
),
|
||||
binding.kind === 'none',
|
||||
isExpanded,
|
||||
`Learn ${definition.label}`,
|
||||
@@ -596,7 +610,7 @@ export function createControllerConfigForm(options: {
|
||||
|
||||
if (isExpanded) {
|
||||
const hint = isLearning
|
||||
? 'Press a button, trigger, or move a stick\u2026'
|
||||
? formatComboLearnHint(holdLabel)
|
||||
: `Currently: ${formatControllerBindingSummary(binding)}`;
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
|
||||
@@ -84,7 +84,6 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
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(),
|
||||
@@ -210,7 +209,6 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
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(),
|
||||
@@ -335,7 +333,6 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
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(),
|
||||
|
||||
@@ -170,7 +170,6 @@ function buildContext() {
|
||||
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(),
|
||||
@@ -294,6 +293,9 @@ test('controller select modal learn mode captures fresh button input and persist
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
// A lone button is only final once released: it could still become a combo's hold.
|
||||
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -423,6 +425,9 @@ test('controller select modal binding badge starts learn mode and persists bindi
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
// A lone button is only final once released: it could still become a combo's hold.
|
||||
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -543,6 +548,9 @@ test('controller select modal edit control starts learn mode and persists bindin
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
// A lone button is only final once released: it could still become a combo's hold.
|
||||
state.controllerRawButtons[11] = { value: 0, pressed: false, touched: false };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -736,6 +744,68 @@ test('controller select modal learns a review combo and saves it to the controll
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal learns a combo for a main action', 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 firstBadge = () => dom.controllerConfigList.children[1].children[1].children[0];
|
||||
firstBadge().dispatch('click');
|
||||
|
||||
const buttons = (pressed: number[]) =>
|
||||
Array.from({ length: 17 }, (_, index) => ({
|
||||
value: pressed.includes(index) ? 1 : 0,
|
||||
pressed: pressed.includes(index),
|
||||
touched: pressed.includes(index),
|
||||
}));
|
||||
|
||||
// Hold L1, then press D-pad right.
|
||||
state.controllerRawButtons = buttons([4]);
|
||||
modal.updateDevices();
|
||||
assert.equal(firstBadge().textContent, 'LB / L1 + \u2026');
|
||||
state.controllerRawButtons = buttons([4, 15]);
|
||||
modal.updateDevices();
|
||||
|
||||
// Let the save finish so the row re-renders with the saved combo.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const binding = {
|
||||
kind: 'button',
|
||||
buttonIndex: 15,
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
};
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: { 'pad-1': { bindings: { toggleLookup: binding } } },
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, binding);
|
||||
assert.equal(firstBadge().textContent, 'LB / L1 + D-pad Right');
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
function findAllByClass(root: any, className: string, found: any[] = []): any[] {
|
||||
for (const child of root.children ?? []) {
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
|
||||
import {
|
||||
createControllerComboCapture,
|
||||
type ControllerCapturedCombo,
|
||||
} from '../handlers/controller-combo-capture.js';
|
||||
import {
|
||||
CONTROLLER_FACE_BUTTON_STYLES,
|
||||
formatControllerReviewInput,
|
||||
@@ -18,11 +21,14 @@ import {
|
||||
} from '../../shared/controller-screens';
|
||||
import {
|
||||
createControllerConfigForm,
|
||||
formatFriendlyInputLabel,
|
||||
getControllerBindingDefinition,
|
||||
getDefaultControllerBinding,
|
||||
getDefaultDpadFallback,
|
||||
} from './controller-config-form.js';
|
||||
|
||||
import type { ControllerDpadFallback, ControllerRawInput } from '../../types';
|
||||
|
||||
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. */
|
||||
@@ -75,12 +81,15 @@ export function createControllerSelectModal(
|
||||
let learningActionId: ControllerBindingKey | null = null;
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||
let screenLearning: {
|
||||
screen: ControllerScreenDefinition;
|
||||
actionId: string;
|
||||
type ComboLearning = {
|
||||
capture: ReturnType<typeof createControllerComboCapture>;
|
||||
holdLabel: string | null;
|
||||
} | null = null;
|
||||
};
|
||||
/** Set with `learningActionId` while learning a discrete row. */
|
||||
let discreteLearning: ComboLearning | null = null;
|
||||
let screenLearning:
|
||||
| (ComboLearning & { screen: ControllerScreenDefinition; actionId: string })
|
||||
| null = null;
|
||||
|
||||
/** The controller the overlay reports in use, fetched when the window opens. */
|
||||
let overlayActiveControllerId: string | null = null;
|
||||
@@ -161,6 +170,36 @@ export function createControllerSelectModal(
|
||||
screenLearning = null;
|
||||
}
|
||||
|
||||
function startComboLearning(): ComboLearning {
|
||||
const config = ctx.state.controllerConfig;
|
||||
const capture = createControllerComboCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
|
||||
return { capture, holdLabel: null };
|
||||
}
|
||||
|
||||
/** Advances a combo capture, showing the hold button so far; returns the finished combo. */
|
||||
function pollComboLearning(
|
||||
learning: ComboLearning,
|
||||
formatHold: (input: ControllerRawInput) => string,
|
||||
): ControllerCapturedCombo | null {
|
||||
const state = learning.capture.poll({
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
});
|
||||
if (state.status === 'done') return state.binding;
|
||||
if (state.status === 'holding') {
|
||||
const holdLabel = formatHold(state.hold);
|
||||
if (holdLabel !== learning.holdLabel) {
|
||||
learning.holdLabel = holdLabel;
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function actionLabel(screen: ControllerScreenDefinition, actionId: string): string {
|
||||
return getControllerScreenAction(screen, actionId).label;
|
||||
}
|
||||
@@ -191,7 +230,6 @@ export function createControllerSelectModal(
|
||||
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' },
|
||||
},
|
||||
getScreenGroups: () => {
|
||||
@@ -203,6 +241,7 @@ export function createControllerSelectModal(
|
||||
}));
|
||||
},
|
||||
getScreenInputContext,
|
||||
getLearningHoldLabel: () => discreteLearning?.holdLabel ?? null,
|
||||
getScreenLearning: () =>
|
||||
screenLearning
|
||||
? {
|
||||
@@ -216,14 +255,9 @@ export function createControllerSelectModal(
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
const config = ctx.state.controllerConfig;
|
||||
const capture = createControllerComboCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
|
||||
discreteLearning = null;
|
||||
const screen = getControllerScreen(screenId);
|
||||
screenLearning = { screen, actionId, capture, holdLabel: null };
|
||||
screenLearning = { ...startComboLearning(), screen, actionId };
|
||||
controllerConfigForm.render();
|
||||
setStatus(
|
||||
`Press a button for ${actionLabel(screen, actionId)}, or hold one and press another for a combo.`,
|
||||
@@ -264,6 +298,17 @@ export function createControllerSelectModal(
|
||||
if (!definition) return;
|
||||
dpadLearningActionId = null;
|
||||
cancelScreenLearning();
|
||||
learningActionId = actionId;
|
||||
if (bindingType === 'discrete') {
|
||||
bindingCapture = null;
|
||||
discreteLearning = startComboLearning();
|
||||
controllerConfigForm.render();
|
||||
setStatus(
|
||||
`Press a button for ${definition.label}, or hold one and press another for a combo.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
discreteLearning = null;
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
@@ -275,22 +320,12 @@ export function createControllerSelectModal(
|
||||
? currentBinding.dpadFallback
|
||||
: 'none';
|
||||
bindingCapture.arm(
|
||||
bindingType === 'axis'
|
||||
? {
|
||||
actionId,
|
||||
bindingType: 'axis',
|
||||
dpadFallback: currentDpadFallback,
|
||||
}
|
||||
: {
|
||||
actionId,
|
||||
bindingType: 'discrete',
|
||||
},
|
||||
{ actionId, bindingType: 'axis', dpadFallback: currentDpadFallback },
|
||||
{
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
},
|
||||
);
|
||||
learningActionId = actionId;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`Waiting for input for ${definition.label}.`);
|
||||
},
|
||||
@@ -307,6 +342,7 @@ export function createControllerSelectModal(
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
learningActionId = null;
|
||||
discreteLearning = null;
|
||||
cancelScreenLearning();
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
@@ -564,6 +600,7 @@ export function createControllerSelectModal(
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
discreteLearning = null;
|
||||
controllerConfigForm.render();
|
||||
setStatus(`${definition?.label ?? actionId} updated.`);
|
||||
} catch (error) {
|
||||
@@ -592,7 +629,7 @@ export function createControllerSelectModal(
|
||||
|
||||
async function saveDpadFallback(
|
||||
actionId: ControllerBindingKey,
|
||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||
dpadFallback: ControllerDpadFallback,
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||
@@ -665,25 +702,31 @@ export function createControllerSelectModal(
|
||||
}
|
||||
}
|
||||
|
||||
if (discreteLearning && learningActionId) {
|
||||
const actionId = learningActionId;
|
||||
const combo = pollComboLearning(discreteLearning, (input) =>
|
||||
formatFriendlyInputLabel(
|
||||
input,
|
||||
getSelectedController()?.mapping,
|
||||
getSelectedControllerConfig()?.faceButtonLabels,
|
||||
),
|
||||
);
|
||||
if (combo) {
|
||||
discreteLearning = null;
|
||||
void saveBinding(actionId, combo.hold ? { ...combo.press, hold: combo.hold } : combo.press);
|
||||
}
|
||||
}
|
||||
|
||||
if (screenLearning) {
|
||||
const learning = screenLearning;
|
||||
const state = learning.capture.poll({
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
});
|
||||
if (state.status === 'holding') {
|
||||
const holdLabel = formatControllerReviewInput(state.hold, getScreenInputContext(), {
|
||||
dpadPrefix: true,
|
||||
});
|
||||
if (holdLabel !== learning.holdLabel) {
|
||||
learning.holdLabel = holdLabel;
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
} else if (state.status === 'done') {
|
||||
const combo = pollComboLearning(learning, (input) =>
|
||||
formatControllerReviewInput(input, getScreenInputContext(), { dpadPrefix: true }),
|
||||
);
|
||||
if (combo) {
|
||||
screenLearning = null;
|
||||
void saveScreenBindings(
|
||||
learning.screen,
|
||||
{ [learning.actionId]: state.binding },
|
||||
{ [learning.actionId]: combo },
|
||||
`${actionLabel(learning.screen, learning.actionId)} updated.`,
|
||||
);
|
||||
}
|
||||
@@ -741,6 +784,7 @@ export function createControllerSelectModal(
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
discreteLearning = null;
|
||||
cancelScreenLearning();
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
ctx.state.controllerSelectGamepadId = null;
|
||||
@@ -760,6 +804,7 @@ export function createControllerSelectModal(
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
discreteLearning = null;
|
||||
cancelScreenLearning();
|
||||
controllerConfigForm.render();
|
||||
setStatus('Controller learn mode cancelled.');
|
||||
|
||||
@@ -590,7 +590,6 @@ function startControllerPolling(): void {
|
||||
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(),
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
ControllerReviewInput,
|
||||
ResolvedControllerReviewBinding,
|
||||
ResolvedControllerReviewBindings,
|
||||
ControllerRawInput,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../types/runtime';
|
||||
|
||||
/**
|
||||
@@ -216,6 +218,26 @@ export function parseControllerReviewInput(value: unknown): ControllerReviewInpu
|
||||
}
|
||||
}
|
||||
|
||||
function parseControllerRawInput(value: unknown): ControllerRawInput | null {
|
||||
const input = parseControllerReviewInput(value);
|
||||
return input?.kind === 'button' || input?.kind === 'axis' ? input : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One main-list binding, as read from the config file and from Alt+C saves over IPC. An invalid
|
||||
* `hold` rejects the whole binding, so a broken combo never becomes a plain press.
|
||||
*/
|
||||
export function parseControllerDiscreteBinding(
|
||||
value: unknown,
|
||||
): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.kind === 'none') return { kind: 'none' };
|
||||
const press = parseControllerRawInput(value);
|
||||
if (!press || value.hold === undefined || value.hold === null) return press;
|
||||
const hold = parseControllerRawInput(value.hold);
|
||||
return hold ? { ...press, hold } : null;
|
||||
}
|
||||
|
||||
/** Accepts `{ "kind": "none" }` or `{ "press": …, "hold"?: … }` (`kind: "combo"` optional). */
|
||||
export function parseControllerReviewBinding(
|
||||
value: unknown,
|
||||
|
||||
@@ -17,7 +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 { isControllerFaceButtonLabels, parseControllerDiscreteBinding } from '../controller-review';
|
||||
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
@@ -142,23 +142,6 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBinding(value: unknown) {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
if (!isInteger(value.buttonIndex) || value.buttonIndex < 0) return null;
|
||||
return { kind: 'button', buttonIndex: value.buttonIndex };
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
|
||||
if (value.direction !== 'negative' && value.direction !== 'positive') return null;
|
||||
return { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBinding(value: unknown) {
|
||||
if (isObject(value) && value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
@@ -230,16 +213,11 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
|
||||
] as const;
|
||||
for (const key of discreteKeys) {
|
||||
if (value[key] === undefined) continue;
|
||||
const parsed = parseDiscreteBinding(value[key]);
|
||||
const parsed = parseControllerDiscreteBinding(value[key]);
|
||||
if (!parsed) return null;
|
||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||
}
|
||||
const axisKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
const axisKeys = ['leftStickHorizontal', 'leftStickVertical', 'rightStickVertical'] as const;
|
||||
for (const key of axisKeys) {
|
||||
if (value[key] === undefined) continue;
|
||||
const parsed = parseAxisBinding(value[key]);
|
||||
|
||||
@@ -191,12 +191,16 @@ export interface ControllerNoneBinding {
|
||||
export interface ControllerButtonInputBinding {
|
||||
kind: 'button';
|
||||
buttonIndex: number;
|
||||
/** Makes this a combo: it fires when this input is pressed while `hold` is down. */
|
||||
hold?: ControllerRawInput;
|
||||
}
|
||||
|
||||
export interface ControllerAxisDirectionInputBinding {
|
||||
kind: 'axis';
|
||||
axisIndex: number;
|
||||
direction: ControllerAxisDirection;
|
||||
/** Makes this a combo: it fires when this input is pressed while `hold` is down. */
|
||||
hold?: ControllerRawInput;
|
||||
}
|
||||
|
||||
export interface ControllerAxisInputBinding {
|
||||
@@ -249,7 +253,6 @@ export interface ControllerBindingsConfig {
|
||||
playNextSubtitle?: ControllerDiscreteBindingConfig;
|
||||
leftStickHorizontal?: ControllerAxisBindingConfig;
|
||||
leftStickVertical?: ControllerAxisBindingConfig;
|
||||
rightStickHorizontal?: ControllerAxisBindingConfig;
|
||||
rightStickVertical?: ControllerAxisBindingConfig;
|
||||
}
|
||||
|
||||
@@ -273,7 +276,6 @@ export interface ResolvedControllerBindingsConfig {
|
||||
playNextSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
leftStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
leftStickVertical?: ResolvedControllerAxisBinding;
|
||||
rightStickHorizontal?: ResolvedControllerAxisBinding;
|
||||
rightStickVertical?: ResolvedControllerAxisBinding;
|
||||
}
|
||||
|
||||
@@ -343,6 +345,9 @@ export type ControllerReviewInput =
|
||||
| { kind: 'face'; letter: ControllerFaceLetter }
|
||||
| { kind: 'dpad'; direction: ControllerDpadDirection };
|
||||
|
||||
/** An input as Learn records it: a raw button index or a raw axis direction. */
|
||||
export type ControllerRawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
|
||||
|
||||
/** Press `press`, optionally while holding `hold`. */
|
||||
export type ControllerReviewBindingConfig =
|
||||
| { kind: 'none' }
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { queryMpvProcessId } from './mpv-process-id';
|
||||
|
||||
// Runs a stand-in mpv IPC server that hands each request line to `onRequest`.
|
||||
async function withFakeMpv(
|
||||
onRequest: (socket: net.Socket, request: string) => void,
|
||||
run: (socketPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mpv-pid-'));
|
||||
const socketPath =
|
||||
process.platform === 'win32'
|
||||
? path.win32.join(String.raw`\\.\pipe`, path.basename(dir))
|
||||
: path.join(dir, 'mpv.sock');
|
||||
const server = net.createServer((socket) => {
|
||||
socket.once('data', (data) => onRequest(socket, data.toString()));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(socketPath, resolve);
|
||||
});
|
||||
await run(socketPath);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('queryMpvProcessId asks mpv for pid and returns the answer', async () => {
|
||||
let request = '';
|
||||
await withFakeMpv(
|
||||
(socket, line) => {
|
||||
request = line;
|
||||
socket.write('{"data":4242,"request_id":1,"error":"success"}\n');
|
||||
},
|
||||
async (socketPath) => {
|
||||
assert.equal(await queryMpvProcessId(socketPath), 4242);
|
||||
},
|
||||
);
|
||||
assert.deepEqual(JSON.parse(request), { command: ['get_property', 'pid'], request_id: 1 });
|
||||
});
|
||||
|
||||
test('queryMpvProcessId skips events and malformed lines, across split chunks', async () => {
|
||||
await withFakeMpv(
|
||||
(socket) => {
|
||||
socket.write('{"event":"idle"}\nnot json\n{"data":42');
|
||||
setTimeout(() => socket.write('42,"request_id":1,"error":"success"}\n'), 10);
|
||||
},
|
||||
async (socketPath) => {
|
||||
assert.equal(await queryMpvProcessId(socketPath), 4242);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('queryMpvProcessId rejects when mpv refuses the property', async () => {
|
||||
await withFakeMpv(
|
||||
(socket) => socket.write('{"request_id":1,"error":"property unavailable"}\n'),
|
||||
async (socketPath) => {
|
||||
await assert.rejects(queryMpvProcessId(socketPath), /no process ID/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('queryMpvProcessId rejects when nothing is listening', async () => {
|
||||
const missing =
|
||||
process.platform === 'win32'
|
||||
? path.win32.join(String.raw`\\.\pipe`, `mpv-pid-missing-${process.pid}`)
|
||||
: path.join(os.tmpdir(), `mpv-pid-missing-${process.pid}.sock`);
|
||||
await assert.rejects(queryMpvProcessId(missing));
|
||||
});
|
||||
|
||||
test('queryMpvProcessId rejects when mpv hangs up or never answers', async () => {
|
||||
await withFakeMpv(
|
||||
(socket) => socket.end(),
|
||||
async (socketPath) => {
|
||||
await assert.rejects(queryMpvProcessId(socketPath), /closed before mpv answered/);
|
||||
},
|
||||
);
|
||||
|
||||
const held: net.Socket[] = [];
|
||||
await withFakeMpv(
|
||||
(socket) => held.push(socket),
|
||||
async (socketPath) => {
|
||||
await assert.rejects(queryMpvProcessId(socketPath, 20), /no answer within 20ms/);
|
||||
held.forEach((socket) => socket.destroy());
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import net from 'node:net';
|
||||
|
||||
const PID_REQUEST_ID = 1;
|
||||
const PID_QUERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
// This runs inside a socket callback, where a parse error would be an uncaught exception.
|
||||
function parseIpcLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the mpv listening on `socketPath` for its process ID over a short-lived IPC connection.
|
||||
export function queryMpvProcessId(
|
||||
socketPath: string,
|
||||
timeoutMs = PID_QUERY_TIMEOUT_MS,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let buffered = '';
|
||||
|
||||
const fail = (message: string): void => {
|
||||
socket.destroy();
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(timeoutMs, () => fail(`no answer within ${timeoutMs}ms`));
|
||||
socket.once('error', (error) => fail(error.message));
|
||||
socket.once('close', () => fail('connection closed before mpv answered'));
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({ command: ['get_property', 'pid'], request_id: PID_REQUEST_ID })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split('\n');
|
||||
buffered = lines.pop() ?? '';
|
||||
|
||||
// mpv also streams events on this connection, so skip to the line answering the request.
|
||||
for (const line of lines) {
|
||||
const message = parseIpcLine(line);
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('request_id' in message) ||
|
||||
message.request_id !== PID_REQUEST_ID
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pid = 'data' in message ? message.data : undefined;
|
||||
if (typeof pid === 'number') {
|
||||
socket.destroy();
|
||||
resolve(pid);
|
||||
} else {
|
||||
fail(`mpv returned no process ID: ${line}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
filterMpvPollResultBySocketPath,
|
||||
matchesMpvSocketPathInCommandLine,
|
||||
} from './mpv-socket-match';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function createPollResult(commandLines: Array<string | null>): MpvPollResult {
|
||||
return {
|
||||
matches: commandLines.map((commandLine, index) => ({
|
||||
hwnd: index + 1,
|
||||
bounds: { x: index * 10, y: 0, width: 1280, height: 720 },
|
||||
area: 1280 * 720,
|
||||
isForeground: index === 0,
|
||||
commandLine,
|
||||
})),
|
||||
focusState: true,
|
||||
windowState: 'visible',
|
||||
};
|
||||
}
|
||||
|
||||
test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => {
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-a',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => {
|
||||
const result = filterMpvPollResultBySocketPath(
|
||||
createPollResult([
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv',
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv',
|
||||
null,
|
||||
]),
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.matches.map((match) => match.hwnd),
|
||||
[2],
|
||||
);
|
||||
assert.equal(result.windowState, 'visible');
|
||||
});
|
||||
|
||||
test('matchesMpvSocketPathInCommandLine rejects socket-path prefix matches', () => {
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-10 video.mkv',
|
||||
'\\\\.\\pipe\\subminer-1',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function matchesMpvSocketPathInCommandLine(
|
||||
commandLine: string,
|
||||
targetSocketPath: string,
|
||||
): boolean {
|
||||
if (!commandLine || !targetSocketPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const escapedSocketPath = escapeRegex(targetSocketPath);
|
||||
return new RegExp(
|
||||
`(?:^|\\s)--input-ipc-server(?:=|\\s+)(?:"${escapedSocketPath}"|${escapedSocketPath})(?=\\s|$)`,
|
||||
'i',
|
||||
).test(commandLine);
|
||||
}
|
||||
|
||||
export function filterMpvPollResultBySocketPath(
|
||||
result: MpvPollResult,
|
||||
targetSocketPath?: string | null,
|
||||
): MpvPollResult {
|
||||
if (!targetSocketPath) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const matches = result.matches.filter(
|
||||
(match) =>
|
||||
typeof match.commandLine === 'string' &&
|
||||
matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath),
|
||||
);
|
||||
|
||||
return {
|
||||
matches,
|
||||
focusState: matches.some((match) => match.isForeground),
|
||||
windowState: matches.length > 0 ? 'visible' : 'not-found',
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import koffi from 'koffi';
|
||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
|
||||
const user32 = koffi.load('user32.dll');
|
||||
const dwmapi = koffi.load('dwmapi.dll');
|
||||
@@ -129,7 +127,6 @@ export interface MpvWindowMatch {
|
||||
bounds: WindowBounds;
|
||||
area: number;
|
||||
isForeground: boolean;
|
||||
commandLine?: string | null;
|
||||
}
|
||||
|
||||
export interface MpvPollResult {
|
||||
@@ -200,102 +197,9 @@ function pruneExpiredProcessNames(nowMs: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessCommandLineCacheEntry =
|
||||
| { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean }
|
||||
| { state: 'pending' }
|
||||
| { state: 'failed'; retryAtMs: number; backoffMs: number };
|
||||
|
||||
const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000;
|
||||
const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000;
|
||||
// A process command line never changes, so a resolved entry only has to expire to survive
|
||||
// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would
|
||||
// otherwise match the wrong window forever). Longer than the process-name TTL because each
|
||||
// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh
|
||||
// runs, so expiry never interrupts window matching.
|
||||
const COMMAND_LINE_CACHE_TTL_MS = 60_000;
|
||||
const processCommandLineCache = new Map<number, ProcessCommandLineCacheEntry>();
|
||||
|
||||
function queryProcessCommandLine(
|
||||
pid: number,
|
||||
onResult: (commandLine: string | null) => void,
|
||||
): void {
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 1500,
|
||||
},
|
||||
(error, stdout) => {
|
||||
const output = error ? '' : stdout.trim();
|
||||
onResult(output.length > 0 ? output : null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves a process command line via a background PowerShell lookup. Returns null until the
|
||||
// first lookup completes; the caller's next poll picks up the cached result. Failures are
|
||||
// negative-cached with exponential backoff: the synchronous version of this lookup could
|
||||
// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with
|
||||
// the forward:true mouse hook) stalled mouse input system-wide.
|
||||
function getProcessCommandLineByPid(pid: number): string | null {
|
||||
const entry = processCommandLineCache.get(pid);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (entry?.state === 'resolved') {
|
||||
if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) {
|
||||
entry.refreshInFlight = true;
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
// A failed refresh is usually a transient query error rather than a dead process
|
||||
// (a gone process owns no window, so it is never looked up again). Keep the last
|
||||
// known command line and re-check after the next TTL.
|
||||
commandLine: commandLine ?? entry.commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
return entry.commandLine;
|
||||
}
|
||||
|
||||
if (entry?.state === 'pending') return null;
|
||||
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
|
||||
|
||||
const nextBackoffMs =
|
||||
entry?.state === 'failed'
|
||||
? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS)
|
||||
: COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS;
|
||||
processCommandLineCache.set(pid, { state: 'pending' });
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
} else {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'failed',
|
||||
retryAtMs: Date.now() + nextBackoffMs,
|
||||
backoffMs: nextBackoffMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||
// With a target PID only that process's windows match, whatever its executable is named.
|
||||
// Without one, any process named mpv matches.
|
||||
export function findMpvWindows(targetPid?: number | null): MpvPollResult {
|
||||
const foregroundHwnd = GetForegroundWindow();
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
@@ -310,15 +214,10 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
const pidValue = pid[0]!;
|
||||
if (pidValue === 0) return true;
|
||||
|
||||
const processName = getCachedProcessNameByPid(pidValue);
|
||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||
|
||||
let commandLine: string | null = null;
|
||||
if (targetSocketPath) {
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||
return true;
|
||||
}
|
||||
if (targetPid != null) {
|
||||
if (pidValue !== targetPid) return true;
|
||||
} else if (getCachedProcessNameByPid(pidValue)?.toLowerCase() !== 'mpv') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsIconic(hwnd)) {
|
||||
@@ -337,7 +236,6 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
bounds,
|
||||
area: bounds.width * bounds.height,
|
||||
isForeground,
|
||||
commandLine,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findWindowsMpvTargetWindowHandle } from './windows-helper';
|
||||
import { createWindowsOverlayOperations, findWindowsMpvTargetWindowHandle } from './windows-helper';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function overlayOperationsHarness() {
|
||||
let nowMs = 0;
|
||||
let failing = true;
|
||||
const warnings: Array<{ message: string; operation: unknown }> = [];
|
||||
const nativeCall = () => {
|
||||
if (failing) throw new Error('native failure');
|
||||
};
|
||||
const operations = createWindowsOverlayOperations({
|
||||
loadWin32: () => ({
|
||||
setOverlayOwner: nativeCall,
|
||||
ensureOverlayTransparency: nativeCall,
|
||||
bindOverlayAboveMpv: nativeCall,
|
||||
clearOverlayOwner: nativeCall,
|
||||
}),
|
||||
warn: (message, details) => warnings.push({ message, operation: details.operation }),
|
||||
now: () => nowMs,
|
||||
});
|
||||
return {
|
||||
operations,
|
||||
warnings,
|
||||
setNow: (value: number) => {
|
||||
nowMs = value;
|
||||
},
|
||||
setFailing: (value: boolean) => {
|
||||
failing = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const [operation, call] of [
|
||||
['set-owner', (ops) => ops.setOwner(1, 2)],
|
||||
['transparency', (ops) => ops.ensureTransparency(1)],
|
||||
['bind-above-mpv', (ops) => ops.bindAboveMpv(1, 2)],
|
||||
['clear-owner', (ops) => ops.clearOwner(1)],
|
||||
] as const satisfies ReadonlyArray<
|
||||
readonly [string, (ops: ReturnType<typeof createWindowsOverlayOperations>) => boolean]
|
||||
>) {
|
||||
test(`${operation} reports failure, rate limits warnings and records recovery`, () => {
|
||||
const h = overlayOperationsHarness();
|
||||
const failed = { message: 'Windows native overlay operation failed', operation };
|
||||
|
||||
assert.equal(call(h.operations), false);
|
||||
assert.deepEqual(h.warnings, [failed]);
|
||||
|
||||
h.setNow(29_999);
|
||||
assert.equal(call(h.operations), false);
|
||||
assert.deepEqual(h.warnings, [failed]);
|
||||
|
||||
h.setNow(30_000);
|
||||
assert.equal(call(h.operations), false);
|
||||
assert.deepEqual(h.warnings, [failed, failed]);
|
||||
|
||||
h.setFailing(false);
|
||||
const recovered = { message: 'Windows native overlay operation recovered', operation };
|
||||
assert.equal(call(h.operations), true);
|
||||
assert.deepEqual(h.warnings, [failed, failed, recovered]);
|
||||
|
||||
assert.equal(call(h.operations), true);
|
||||
assert.equal(h.warnings.length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('native overlay operations rate limit independently', () => {
|
||||
const h = overlayOperationsHarness();
|
||||
h.operations.setOwner(1, 2);
|
||||
h.operations.clearOwner(1);
|
||||
assert.deepEqual(
|
||||
h.warnings.map((warning) => warning.operation),
|
||||
['set-owner', 'clear-owner'],
|
||||
);
|
||||
});
|
||||
|
||||
test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => {
|
||||
const result: MpvPollResult = {
|
||||
matches: [
|
||||
|
||||
@@ -17,11 +17,85 @@
|
||||
*/
|
||||
|
||||
import type { MpvPollResult } from './win32';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
const log = createLogger('overlay:windows');
|
||||
|
||||
type OverlayWin32 = Pick<
|
||||
typeof import('./win32'),
|
||||
'setOverlayOwner' | 'ensureOverlayTransparency' | 'bindOverlayAboveMpv' | 'clearOverlayOwner'
|
||||
>;
|
||||
|
||||
// These run on every visibility update, so each operation warns at most every 30 seconds
|
||||
// while it keeps failing, and once more when it recovers.
|
||||
export function createWindowsOverlayOperations(deps: {
|
||||
loadWin32: () => OverlayWin32;
|
||||
warn: (message: string, details: Record<string, unknown>) => void;
|
||||
now: () => number;
|
||||
}) {
|
||||
const failures = new Map<string, number>();
|
||||
|
||||
const run = (
|
||||
operation: string,
|
||||
handles: { overlayHwnd: number; mpvHwnd?: number },
|
||||
action: (win32: OverlayWin32) => void,
|
||||
): boolean => {
|
||||
try {
|
||||
action(deps.loadWin32());
|
||||
if (failures.delete(operation)) {
|
||||
deps.warn('Windows native overlay operation recovered', { operation, ...handles });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const lastWarning = failures.get(operation);
|
||||
if (lastWarning === undefined || deps.now() - lastWarning >= 30_000) {
|
||||
deps.warn('Windows native overlay operation failed', {
|
||||
operation,
|
||||
...handles,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
failures.set(operation, deps.now());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
setOwner: (overlayHwnd: number, mpvHwnd: number): boolean =>
|
||||
run('set-owner', { overlayHwnd, mpvHwnd }, (win32) => {
|
||||
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
|
||||
}),
|
||||
ensureTransparency: (overlayHwnd: number): boolean =>
|
||||
run('transparency', { overlayHwnd }, (win32) => {
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
}),
|
||||
bindAboveMpv: (overlayHwnd: number, mpvHwnd: number): boolean =>
|
||||
run('bind-above-mpv', { overlayHwnd, mpvHwnd }, (win32) => {
|
||||
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
}),
|
||||
clearOwner: (overlayHwnd: number): boolean =>
|
||||
run('clear-owner', { overlayHwnd }, (win32) => {
|
||||
win32.clearOverlayOwner(overlayHwnd);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function loadWin32(): typeof import('./win32') {
|
||||
return require('./win32') as typeof import('./win32');
|
||||
}
|
||||
|
||||
const overlayOperations = createWindowsOverlayOperations({
|
||||
loadWin32,
|
||||
warn: (message, details) => log.warn(message, details),
|
||||
now: Date.now,
|
||||
});
|
||||
|
||||
export const setWindowsOverlayOwner = overlayOperations.setOwner;
|
||||
export const ensureWindowsOverlayTransparency = overlayOperations.ensureTransparency;
|
||||
export const bindWindowsOverlayAboveMpv = overlayOperations.bindAboveMpv;
|
||||
export const clearWindowsOverlayOwner = overlayOperations.clearOwner;
|
||||
|
||||
export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null {
|
||||
const poll = result ?? loadWin32().findMpvWindows();
|
||||
const focused = poll.matches.find((match) => match.isForeground);
|
||||
@@ -31,44 +105,6 @@ export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number
|
||||
return best?.hwnd ?? null;
|
||||
}
|
||||
|
||||
export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean {
|
||||
try {
|
||||
loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean {
|
||||
try {
|
||||
loadWin32().ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = loadWin32();
|
||||
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWindowsOverlayOwner(overlayHwnd: number): boolean {
|
||||
try {
|
||||
loadWin32().clearOverlayOwner(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWindowsForegroundProcessName(): string | null {
|
||||
try {
|
||||
return loadWin32().getForegroundProcessName();
|
||||
|
||||
@@ -265,3 +265,189 @@ test('WindowsWindowTracker keeps tracking through repeated transient minimized r
|
||||
assert.equal(tracker.isTargetWindowMinimized(), false);
|
||||
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
const SOCKET_PATH = String.raw`\\.\pipe\subminer-socket`;
|
||||
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function pollOnce(tracker: WindowsWindowTracker): void {
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
}
|
||||
|
||||
test('WindowsWindowTracker follows the process that answers on the target socket', async () => {
|
||||
const queriedSockets: string[] = [];
|
||||
const polledPids: Array<number | null> = [];
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
queryMpvProcessId: async (socketPath) => {
|
||||
queriedSockets.push(socketPath);
|
||||
return 4242;
|
||||
},
|
||||
pollMpvWindows: (targetPid) => {
|
||||
polledPids.push(targetPid);
|
||||
return mpvVisible();
|
||||
},
|
||||
});
|
||||
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
assert.deepEqual(queriedSockets, [SOCKET_PATH]);
|
||||
assert.deepEqual(polledPids, [4242]);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker keeps polling while a process lookup is pending', async () => {
|
||||
let queries = 0;
|
||||
let answer: (pid: number) => void = () => {};
|
||||
let now = 1_000;
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
now: () => now,
|
||||
queryMpvProcessId: () => {
|
||||
queries += 1;
|
||||
return new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
},
|
||||
pollMpvWindows: () => mpvVisible(),
|
||||
});
|
||||
|
||||
for (let poll = 0; poll < 120; poll += 1) {
|
||||
pollOnce(tracker);
|
||||
now += 250;
|
||||
}
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
assert.equal(queries, 1);
|
||||
|
||||
answer(4242);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker retries a failed lookup at a limited rate', async () => {
|
||||
let queries = 0;
|
||||
let now = 1_000;
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
now: () => now,
|
||||
queryMpvProcessId: async () => {
|
||||
queries += 1;
|
||||
if (queries === 1) throw new Error('connect ENOENT');
|
||||
return 4242;
|
||||
},
|
||||
pollMpvWindows: () => mpvVisible(),
|
||||
});
|
||||
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
now += 250;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
assert.equal(queries, 1);
|
||||
|
||||
now += 1_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(queries, 2);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker moves to the new process when mpv restarts on the same socket', async () => {
|
||||
let currentPid = 1111;
|
||||
let now = 1_000;
|
||||
let queries = 0;
|
||||
const polledPids: Array<number | null> = [];
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
now: () => now,
|
||||
queryMpvProcessId: async () => {
|
||||
queries += 1;
|
||||
return currentPid;
|
||||
},
|
||||
// Only the live process still owns a window.
|
||||
pollMpvWindows: (targetPid) => {
|
||||
polledPids.push(targetPid);
|
||||
return targetPid === currentPid ? mpvVisible() : mpvNotFound;
|
||||
},
|
||||
});
|
||||
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
currentPid = 2222;
|
||||
now += 2_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
assert.deepEqual(tracker.getGeometry(), { x: 0, y: 0, width: 1280, height: 720 });
|
||||
assert.equal(polledPids.at(-1), 2222);
|
||||
assert.equal(queries, 2);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker revalidates a PID even while its window remains visible', async () => {
|
||||
let currentPid = 1111;
|
||||
let now = 0;
|
||||
const polledPids: Array<number | null> = [];
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
now: () => now,
|
||||
queryMpvProcessId: async () => currentPid,
|
||||
// Simulates PID reuse: the old PID still has a visible, unrelated window.
|
||||
pollMpvWindows: (pid) => {
|
||||
polledPids.push(pid);
|
||||
return mpvVisible();
|
||||
},
|
||||
});
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
currentPid = 2222;
|
||||
now = 10_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(polledPids.at(-1), 2222);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker expires an unvalidated PID and recovers after IPC returns', async () => {
|
||||
let available = true;
|
||||
let now = 0;
|
||||
const polledPids: Array<number | null> = [];
|
||||
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
|
||||
now: () => now,
|
||||
queryMpvProcessId: async () => {
|
||||
if (!available) throw new Error('IPC unavailable');
|
||||
return 4242;
|
||||
},
|
||||
pollMpvWindows: (pid) => {
|
||||
polledPids.push(pid);
|
||||
return mpvVisible();
|
||||
},
|
||||
trackingLossGraceMs: 0,
|
||||
maxConsecutiveMisses: 1,
|
||||
});
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
available = false;
|
||||
now = 10_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
polledPids.length = 0;
|
||||
now = 30_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
assert.deepEqual(polledPids, []);
|
||||
available = true;
|
||||
now += 1_000;
|
||||
pollOnce(tracker);
|
||||
await flushPromises();
|
||||
pollOnce(tracker);
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
assert.equal(polledPids.at(-1), 4242);
|
||||
});
|
||||
@@ -19,27 +19,35 @@
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { MpvPollResult } from './win32';
|
||||
import { queryMpvProcessId } from './mpv-process-id';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
const log = createLogger('tracker').child('windows');
|
||||
|
||||
const MPV_PID_LOOKUP_INTERVAL_MS = 1_000;
|
||||
const MPV_PID_REVALIDATE_INTERVAL_MS = 10_000;
|
||||
const MPV_PID_MAX_AGE_MS = 30_000;
|
||||
const NO_MPV_WINDOW: MpvPollResult = { matches: [], focusState: false, windowState: 'not-found' };
|
||||
|
||||
type WindowsTrackerDeps = {
|
||||
pollMpvWindows?: () => MpvPollResult;
|
||||
pollMpvWindows?: (targetPid: number | null) => MpvPollResult;
|
||||
queryMpvProcessId?: (socketPath: string) => Promise<number>;
|
||||
maxConsecutiveMisses?: number;
|
||||
trackingLossGraceMs?: number;
|
||||
minimizedTrackingLossGraceMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult {
|
||||
function defaultPollMpvWindows(targetPid: number | null): MpvPollResult {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
return win32.findMpvWindows(_targetMpvSocketPath);
|
||||
return win32.findMpvWindows(targetPid);
|
||||
}
|
||||
|
||||
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
private readonly pollMpvWindows: () => MpvPollResult;
|
||||
private readonly pollMpvWindows: (targetPid: number | null) => MpvPollResult;
|
||||
private readonly queryMpvProcessId: (socketPath: string) => Promise<number>;
|
||||
private readonly maxConsecutiveMisses: number;
|
||||
private readonly trackingLossGraceMs: number;
|
||||
private readonly minimizedTrackingLossGraceMs: number;
|
||||
@@ -51,12 +59,17 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private targetWindowMinimized = false;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private currentTargetWindowHwnd: number | null = null;
|
||||
private targetMpvPid: number | null = null;
|
||||
private pidLookupInFlight = false;
|
||||
private lastPidLookupAtMs: number | null = null;
|
||||
private lastPidConfirmedAtMs: number | null = null;
|
||||
private pidLookupFailureLogged = false;
|
||||
|
||||
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
|
||||
super();
|
||||
this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null;
|
||||
this.pollMpvWindows =
|
||||
deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
|
||||
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
|
||||
this.queryMpvProcessId = deps.queryMpvProcessId ?? queryMpvProcessId;
|
||||
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
|
||||
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
|
||||
this.minimizedTrackingLossGraceMs = Math.max(
|
||||
@@ -86,6 +99,16 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
return this.currentTargetWindowHwnd;
|
||||
}
|
||||
|
||||
getDiagnosticState() {
|
||||
return {
|
||||
socketPath: this.targetMpvSocketPath,
|
||||
targetPid: this.targetMpvPid,
|
||||
targetHwnd: this.currentTargetWindowHwnd,
|
||||
pidLookupPending: this.pidLookupInFlight,
|
||||
pidAgeMs: this.lastPidConfirmedAtMs === null ? null : this.now() - this.lastPidConfirmedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
private maybeLogPollError(error: Error): void {
|
||||
const now = Date.now();
|
||||
const fingerprint = error.message;
|
||||
@@ -142,12 +165,79 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
};
|
||||
}
|
||||
|
||||
// Asks the mpv on the target socket which process it is. Runs in the background so a slow
|
||||
// answer never stalls the poll, which shares the main thread with the mouse hook.
|
||||
private refreshTargetMpvPid(socketPath: string, intervalMs = MPV_PID_LOOKUP_INTERVAL_MS): void {
|
||||
const nowMs = this.now();
|
||||
if (
|
||||
this.pidLookupInFlight ||
|
||||
(this.lastPidLookupAtMs !== null && nowMs - this.lastPidLookupAtMs < intervalMs)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pidLookupInFlight = true;
|
||||
this.lastPidLookupAtMs = nowMs;
|
||||
this.queryMpvProcessId(socketPath)
|
||||
.then(
|
||||
(pid) => {
|
||||
this.targetMpvPid = pid;
|
||||
this.lastPidConfirmedAtMs = this.now();
|
||||
if (this.pidLookupFailureLogged) {
|
||||
log.warn('Windows mpv PID lookup recovered', this.getDiagnosticState());
|
||||
}
|
||||
this.pidLookupFailureLogged = false;
|
||||
},
|
||||
(error: unknown) => {
|
||||
// The overlay stays hidden until mpv is identified, so leave a trace of why it isn't.
|
||||
if (this.pidLookupFailureLogged) return;
|
||||
this.pidLookupFailureLogged = true;
|
||||
log.warn('Windows mpv PID lookup failed; overlay attachment may be unavailable', {
|
||||
...this.getDiagnosticState(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this.pidLookupInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
private pollTargetWindows(): MpvPollResult {
|
||||
const socketPath = this.targetMpvSocketPath;
|
||||
if (!socketPath) return this.pollMpvWindows(null);
|
||||
|
||||
// Windows can reuse a PID for an unrelated visible window. A cached match must
|
||||
// remain tied to the process answering on our socket, even while it is visible.
|
||||
if (
|
||||
this.lastPidConfirmedAtMs !== null &&
|
||||
this.now() - this.lastPidConfirmedAtMs >= MPV_PID_MAX_AGE_MS
|
||||
) {
|
||||
this.targetMpvPid = null;
|
||||
}
|
||||
|
||||
if (this.targetMpvPid === null) {
|
||||
this.refreshTargetMpvPid(socketPath);
|
||||
return NO_MPV_WINDOW;
|
||||
}
|
||||
|
||||
const result = this.pollMpvWindows(this.targetMpvPid);
|
||||
// A new mpv may have taken over the socket after the known process exited.
|
||||
this.refreshTargetMpvPid(
|
||||
socketPath,
|
||||
result.windowState === 'not-found'
|
||||
? MPV_PID_LOOKUP_INTERVAL_MS
|
||||
: MPV_PID_REVALIDATE_INTERVAL_MS,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
if (this.pollInFlight) return;
|
||||
this.pollInFlight = true;
|
||||
|
||||
try {
|
||||
const result = this.pollMpvWindows();
|
||||
const result = this.pollTargetWindows();
|
||||
const best = this.selectBestMatch(result);
|
||||
|
||||
if (best) {
|
||||
|
||||
Reference in new issue
Block a user