Compare commits

...
7 Commits
Author SHA1 Message Date
sudacode 15cb9fe918 test(launcher): allow Windows bootstrap integration startup time
Cold and cached launches each have a 15-second command timeout. Give the integration test time for both launches and executable copies instead of the default five-second budget on hosted Windows runners.
2026-10-01 01:42:10 -07:00
sudacode db45a70ec2 chore(release): prepare v0.20.1-beta.1 2026-10-01 01:21:59 -07:00
sudacode 9e79fa93c2 feat(overlay): forward mpv mouse button and wheel bindings (#279) 2026-10-01 00:44:51 -07:00
sudacode 9fadbc8cdd docs: align README with installation guide and fix Arch MeCab install
- Install MeCab from the AUR (mecab-git) on Arch, since it is not in the official repos
- Rewrite the README feature blurbs, requirements table, and quick start so they match docs-site/installation.md
- Add fuse2, X11 window tools, whisper.cpp, and the thumbnail tools to the README requirements
- Drop the playlist browser and texthooker sections and the standalone launcher download steps
- Mention AniList as a guessit consumer in the installation guide
2026-09-30 23:45:53 -07:00
sudacode ffbbe698fc fix(overlay): reload imported mpv keys after mpv connects (#278)
* fix(overlay): reload imported mpv keys after mpv connects

The overlay fetched mpv's input-bindings before SubMiner connected to mpv,
cached an empty list, and never refetched because the connect-time refresh
only broadcast session bindings when their compiled signature changed. Keys
from input.conf and mpv defaults (9/0 volume, m mute) then did nothing while
the overlay had focus, most often in mpv.backend x11 mode.

Main now emits mpv-input-bindings:changed whenever the discovered mpv key
set or client changes, and the renderer refreshes its imported keys on it.

* fix(overlay): subscribe to mpv key changes before initial discovery

The renderer registered the mpv-input-bindings:changed listener only after awaiting setupMpvInputForwarding and several other startup calls. If mpv connected in that window, the event was dropped and the imported mpv keys stayed empty.
2026-09-30 23:24:39 -07:00
sudacode 7b72dd2cc4 build(deps): bump axios to 1.20.0 2026-09-30 20:21:02 -07:00
sudacode 416cd7e9ac fix(overlay): keep Hyprland IPC env when forcing mpv X11 backend
- Stop stripping HYPRLAND_INSTANCE_SIGNATURE in applyX11EnvOverrides so the overlay can use hyprctl to lift Hyprland's fullscreen input block
- Fixes the overlay becoming unclickable over fullscreen mpv with mpv.backend: x11
- Update launcher and backend tests, add changelog fragment
2026-09-30 19:50:24 -07:00
37 changed files with 789 additions and 326 deletions

No files matched your search

+55 -65
View File
@@ -4,7 +4,7 @@
# SubMiner
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -23,7 +23,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
### Dictionary Lookups
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
<div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
@@ -33,7 +33,7 @@ Hover over any word and trigger a lookup to get the full Yomitan popup - definit
### Instant Anki Mining
Create an Anki card with the sentence, audio clip, screenshot, and machine translation from the exact playback moment with one key press, click, or controller input.
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
<div align="center">
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
@@ -43,7 +43,7 @@ Create an Anki card with the sentence, audio clip, screenshot, and machine trans
### Reading Annotations
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Grammar-only tokens and particles render as plain text so you focus on what matters.
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
<div align="center">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
@@ -53,7 +53,7 @@ Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targe
### Immersion Dashboard
Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
A stats dashboard tracks watch time, vocabulary growth, mining throughput, session history, and trends. Everything stays on your machine, with no third-party tracking.
<div align="center">
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
@@ -61,22 +61,12 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
<br>
### Playlist Browser
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
<div align="center">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
</div>
<br>
### Integrations
<table>
<tr>
<td><b>YouTube</b></td>
<td>Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
<td>Play YouTube URLs with subtitle tracks picked by your language priorities, or choose tracks yourself in the overlay picker (<code>Ctrl+Alt+C</code>). Requires <code>yt-dlp</code></td>
</tr>
<tr>
<td><b>AniList</b></td>
@@ -84,19 +74,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr>
<tr>
<td><b>Jellyfin</b></td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
<td>Browse your Jellyfin library, or cast to SubMiner from any Jellyfin client. Setup and discovery live in the tray menu</td>
</tr>
<tr>
<td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td>
</tr>
<tr>
<td><b>Local Subtitle Generation</b></td>
<td>Generate Japanese subtitles from local audio in a standalone modal (<code>Ctrl+Shift+G</code>), the sidebar button, or launcher, with progress and optional managed model downloads. Requires whisper.cpp and FFmpeg. Optional Silero speech detection prioritizes dialogue in separately timed passages. <a href="https://docs.subminer.moe/main/subtitle-generation">Setup guide</a></td>
<td>Search and download Japanese subtitles. Requires a free Jimaku API key</td>
</tr>
<tr>
<td><b>TsukiHime</b></td>
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>). Requires <code>xz</code> on your <code>PATH</code></td>
</tr>
<tr>
<td><b>Subtitle generation</b></td>
<td>Transcribe a video's audio into Japanese subtitles locally from the generation modal (<code>Ctrl+Shift+G</code>), the subtitle sidebar, or the launcher. SubMiner can download models for you; optional Silero speech detection helps focus on dialogue. Requires whisper.cpp and FFmpeg. <a href="https://docs.subminer.moe/subtitle-generation">Setup guide</a></td>
</tr>
<tr>
<td><b>AniSkip</b></td>
@@ -104,7 +94,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr>
<tr>
<td><b>alass / ffsubsync</b></td>
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
<td>Retime a subtitle against the audio or another subtitle track (<code>Ctrl+Alt+S</code>). Requires <code>alass</code> or <code>ffsubsync</code>; set <code>subsync.alass_path</code> or <code>subsync.ffsubsync_path</code> if they are not in <code>/usr/bin</code></td>
</tr>
<tr>
<td><b>WebSocket</b></td>
@@ -112,29 +102,29 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr>
</table>
<div align="center">
<img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page receiving annotated subtitle lines via WebSocket">
</div>
<br>
---
## Requirements
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
SubMiner runs on Linux, macOS 11+, and Windows 10+. Only **mpv** is required to run it (plus `fuse2` for the Linux AppImage). Mining cards also needs Anki with the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on. Everything else is optional.
| Dependency | Status | What it does |
| -------------------- | ---------------- | -------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
| Dependency | Status | What it does |
| ------------------------ | ---------------- | -------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner draws over |
| fuse2 | Required (Linux) | Running the AppImage |
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips and screenshots on cards |
| MeCab + mecab-ipadic | Recommended | More accurate N+1, JLPT, and frequency highlighting |
| xdotool + xwininfo | Required (X11) | Window tracking on desktops other than Hyprland or Sway |
| yt-dlp | Optional | YouTube playback |
| xz | Optional | TsukiHime subtitle downloads (most Linux distros already have it) |
| alass / ffsubsync | Optional | Subtitle sync |
| whisper.cpp | Optional | [Subtitle generation](https://docs.subminer.moe/subtitle-generation) |
| guessit | Optional | Better title, season, and episode detection for AniSkip and AniList |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (rofi is Linux only) |
| chafa, ffmpegthumbnailer | Optional | Thumbnail previews in the launcher pickers |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -142,9 +132,12 @@ Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to min
**Arch Linux:**
```bash
sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
sudo pacman -S --needed mpv ffmpeg
paru -S --needed mecab-git mecab-ipadic # MeCab is only in the AUR
```
On desktops other than Hyprland or Sway, also install `xdotool` and `xorg-xwininfo`.
**macOS:**
```bash
@@ -158,16 +151,16 @@ winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. ffmpeg must be on `PATH`; mpv does not have to be. If `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or enter the full path to `mpv.exe` during first-run setup.
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything. It is the only one that also carries `xz`:
```powershell
scoop bucket add extras
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
```
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
See the [installation guide](https://docs.subminer.moe/installation#_1-install-requirements) for Ubuntu, Debian, and Fedora commands and the full optional package lists.
</details>
@@ -184,6 +177,8 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
paru -S subminer-bin
```
Includes the AppImage and the `subminer` command.
</details>
<details>
@@ -195,14 +190,7 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppI
&& chmod +x ~/.local/bin/SubMiner.AppImage
```
The AppImage is all you need. First-run setup can install the optional `subminer` command-line launcher. Every current launcher uses Bun included with the app, so you do not need Bun installed or on `PATH`.
You can also download the launcher wrapper directly:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
The AppImage is all you need. First-run setup can install the optional `subminer` command, which runs on a copy of Bun bundled with the app, so you do not need Bun installed.
</details>
@@ -211,14 +199,14 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility**, or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**.
</details>
<details>
<summary><b>Windows</b></summary>
Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
For terminal use, download `subminer.cmd`. It locates the installed app and uses its private Bun runtime.
Download and run the latest installer (`SubMiner-<version>.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). A portable `.zip` is also available.
</details>
@@ -231,28 +219,30 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
### 2. Launch & Set Up
Run the installed app and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher. Setup records a custom app location when needed, and the wrapper runs with the app's private Bun runtime.
Start SubMiner and the setup window opens on first launch. It creates your config file, imports Yomitan dictionaries (you need at least one for lookups), and can install the `subminer` command. On Windows it also creates a **SubMiner mpv** shortcut.
```bash
# Linux
~/.local/bin/SubMiner.AppImage --setup
# macOS
open -a SubMiner --args --setup
subminer app --setup # AUR
~/.local/bin/SubMiner.AppImage --setup # AppImage
```
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
On **macOS**, open `SubMiner.app` from `/Applications`. On **Windows**, run SubMiner from the Start menu. To reopen setup later, run `subminer app --setup`.
For card creation, keep Anki open with AnkiConnect installed. SubMiner connects to it at its default address with no extra setup.
### 3. Mine
```bash
subminer video.mkv # launch mpv with SubMiner
subminer video.mkv # play a video with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse history, then previous / replay / next / select / quit
subminer -H # watch history: replay, next, or previous episode
subminer doctor # check your setup
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
On **Windows**, double-click the **SubMiner mpv** shortcut or drag a video file onto it.
Starting mpv some other way? See [Launching mpv yourself](https://docs.subminer.moe/installation#launching-mpv-yourself) for the IPC socket option the overlay needs.
## Documentation
+2 -2
View File
@@ -8,7 +8,7 @@
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"axios": "^1.20.0",
"commander": "^14.0.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
@@ -259,7 +259,7 @@
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+1
View File
@@ -4,3 +4,4 @@ area: docs
- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
- The configuration reference now has a short explanation and a key/default table for each config block.
- Fixed docs that no longer matched current behavior.
- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
+2 -2
View File
@@ -260,10 +260,10 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
}
```
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`.
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
- Unused single-key bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
### Shortcuts configuration
+14 -14
View File
@@ -14,18 +14,18 @@ Getting started takes three steps:
Only mpv is required. Install ffmpeg too unless you are fine with cards that have no audio or screenshot.
| Dependency | Needed for | Platforms |
| ------------------------ | ------------------------------------------------------------------------------------------- | ------------ |
| mpv | Required. The player SubMiner draws over. | All |
| fuse2 | Required to run the AppImage. | Linux |
| ffmpeg | Recommended. Audio clips and screenshots on cards. Without it those fields stay empty. | All |
| MeCab + mecab-ipadic | Recommended. More accurate N+1, JLPT, and frequency highlighting. | All |
| yt-dlp | YouTube playback. | All |
| xz | [TsukiHime](/tsukihime-integration) subtitle downloads. Most Linux distros already have it. | All |
| guessit | Better title, season, and episode detection for [AniSkip](/aniskip-integration). | All |
| alass or ffsubsync | Subtitle syncing. You need at least one to use it. | All |
| fzf, rofi | The file pickers in the `subminer` command (rofi is Linux only). | Linux, macOS |
| chafa, ffmpegthumbnailer | Thumbnail previews in the pickers. | Linux, macOS |
| Dependency | Needed for | Platforms |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------ |
| mpv | Required. The player SubMiner draws over. | All |
| fuse2 | Required to run the AppImage. | Linux |
| ffmpeg | Recommended. Audio clips and screenshots on cards. Without it those fields stay empty. | All |
| MeCab + mecab-ipadic | Recommended. More accurate N+1, JLPT, and frequency highlighting. | All |
| yt-dlp | YouTube playback. | All |
| xz | [TsukiHime](/tsukihime-integration) subtitle downloads. Most Linux distros already have it. | All |
| guessit | Better title, season, and episode detection for [AniSkip](/aniskip-integration) and [AniList](/anilist-integration). | All |
| alass or ffsubsync | Subtitle syncing. You need at least one to use it. | All |
| fzf, rofi | The file pickers in the `subminer` command (rofi is Linux only). | Linux, macOS |
| chafa, ffmpegthumbnailer | Thumbnail previews in the pickers. | Linux, macOS |
To generate Japanese subtitles from audio, you also need whisper.cpp. See [Subtitle generation](/subtitle-generation).
@@ -42,8 +42,8 @@ SubMiner needs to track the mpv window, and how it does that depends on your des
```bash
sudo pacman -S --needed mpv ffmpeg
# Recommended
sudo pacman -S --needed mecab mecab-ipadic
# Recommended (MeCab is only in the AUR)
paru -S --needed mecab-git mecab-ipadic
# Optional
sudo pacman -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer
# Optional: subtitle sync (install at least one)
+4 -3
View File
@@ -132,15 +132,16 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
"keybindings": [
{ "key": "m", "command": ["cycle", "mute"] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
{ "key": "Ctrl+WHEEL_UP", "command": ["add", "sub-scale", 0.1] },
{ "key": "Space", "command": null },
],
}
```
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
## Automatic mpv bindings
The overlay reads single-key bindings from the running mpv (`input.conf`, mpv defaults, and scripts). If SubMiner does not handle a key, it passes it to mpv. SubMiner shortcuts and `keybindings` entries win, including ones set to `null`. Keys are not forwarded while you type in a text field, use an overlay menu, or have a Yomitan popup open.
The overlay reads single-key, mouse button, and scroll wheel bindings from the running mpv (`input.conf`, mpv defaults, and scripts). If SubMiner does not handle the input, it passes it to mpv, so mpv's defaults like double-click for fullscreen and the wheel for volume work over the overlay. SubMiner shortcuts and `keybindings` entries win, including ones set to `null`, and right-click always pauses. Keys and clicks are not forwarded while you type in a text field, use an overlay menu, or have a Yomitan popup open, and clicks on subtitles or overlay controls stay with SubMiner. Scrolling over an overlay menu, the subtitle sidebar, or notification history scrolls that panel instead.
Mouse buttons, keypad and media keys, and key sequences are not imported. Bindings imported this way do not appear in session help. If you add an mpv binding while SubMiner runs, refocus the overlay to pick it up.
Keypad and media keys, key sequences, and mouse movement are not imported. Bindings imported this way do not appear in session help. If you add an mpv binding while SubMiner runs, refocus the overlay to pick it up.
+10 -2
View File
@@ -48,9 +48,17 @@ Automatic mpv keyboard discovery uses the `get-mpv-input-bindings` IPC request a
configured keys, including disabled entries. `src/shared/mpv-input-bindings.ts`
validates discovered keys and translates browser input. The renderer's
`handlers/mpv-input-forwarding.ts` keeps the session lookup, coalesces asynchronous
refreshes, and releases held keys on blur or disposal. `handlers/keyboard.ts` runs
refreshes, and releases held keys on blur or disposal. Discovered `WHEEL_*` bindings forward
as `keypress <key> <notches>` (Chromium reports 120 px per notch), which matches mpv's own
precise-scroll scaling. `MBTN_*` buttons forward as held `keydown`/`keyup` when mpv binds the
button or its `_DBL` variant, so mpv synthesizes double-clicks itself. Configured keybindings and
the built-in right-click pause run first. Forwarding is the only way pointer input reaches mpv on
Hyprland, where the overlay cannot be made click-through. `handlers/keyboard.ts` runs
this fallback after SubMiner controls and refreshes on startup, a delayed startup
pass, focus, and binding reload. Discovery does not enter compiled session bindings,
pass, focus, binding reload, and the `mpv-input-bindings:changed` event. Main sends
that event from `session-bindings-runtime.ts` when mpv's discovered key set changes,
including the first discovery after connecting, because the overlay often loads
before mpv connects and the compiled session bindings may not change. Discovery does not enter compiled session bindings,
the plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
+2 -1
View File
@@ -179,7 +179,8 @@ test('buildMpvEnv forces X11 by dropping Wayland hints when backend resolves to
assert.equal(env.DISPLAY, ':1');
assert.equal(env.WAYLAND_DISPLAY, undefined);
assert.equal(env.XDG_SESSION_TYPE, 'x11');
assert.equal(env.HYPRLAND_INSTANCE_SIGNATURE, undefined);
// The overlay inherits this env and needs hyprctl for its XWayland window.
assert.equal(env.HYPRLAND_INSTANCE_SIGNATURE, 'hypr');
assert.equal(env.SWAYSOCK, undefined);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.20.0",
"version": "0.20.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",
@@ -115,7 +115,7 @@
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"axios": "^1.20.0",
"commander": "^14.0.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
+4
View File
@@ -29,6 +29,10 @@ local KEY_NAME_MAP = {
MBTN_RIGHT = "MBTN_RIGHT",
MBTN_BACK = "MBTN_BACK",
MBTN_FORWARD = "MBTN_FORWARD",
WHEEL_UP = "WHEEL_UP",
WHEEL_DOWN = "WHEEL_DOWN",
WHEEL_LEFT = "WHEEL_LEFT",
WHEEL_RIGHT = "WHEEL_RIGHT",
}
local MODIFIER_MAP = {
+63 -106
View File
@@ -1,133 +1,90 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-version: 0.20.0-beta.2; since: v0.20.0-beta.1 -->
## Changes since v0.20.0-beta.1
- Added an optional subtitle selection modal for primary and secondary mpv subtitle tracks. Enable it in Settings > Behavior, then press `g` followed by `s`. Disabling it restores mpv's subtitle selection binding.
- Single-key shortcut actions now take priority over configured multi-key sequence prefixes, conflicting sequences are disabled with a warning, and existing `y` commands remain reserved.
- Jellyfin casting and playback now honor a configured mpv executable path, allowing playback when mpv is installed outside the system PATH, and portable plugins located beside that executable are now detected.
- Fixed word-card sentence furigana falling out of sync with full stats-search context and expanded timing-review selections; stale furigana is now cleared if regeneration fails.
<!-- prerelease-version: 0.20.1-beta.1 -->
## Highlights
### Added
- **Japanese Subtitle Generation**:
- Generate Japanese subtitles locally with whisper.cpp, right from a modal (`Ctrl+Shift+G`), the subtitle sidebar's generation button when no subtitles are loaded, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv when it's done.
- Pick and download an official multilingual Whisper model in-app (including smaller quantized variants), or point Settings at one you already have. SubMiner recommends `large-v3-turbo` when CUDA is available and `small` otherwise, and tells you up front if `whisper-cli`, `ffmpeg`, or `ffprobe` can't be found.
- An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped.
- Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches.
- **Media Timing Review Frame Picker**:
- The screenshot used for a mined card can now be chosen independently of the audio clip, with its own live preview, time slider, and frame-by-frame stepping.
- Works for local video and for seekable remote streams like Jellyfin.
- **Overlay Keybinding Pickup**: The overlay now recognizes your mpv keybindings (from mpv's defaults, `input.conf`, and loaded scripts) as long as they don't conflict with SubMiner's own controls. Picked-up bindings work for the session but won't show up in the help menu.
- **Subtitle Selection Modal**:
- An optional subtitle selection modal lets you pick primary and secondary mpv subtitle tracks without leaving the overlay.
- Enable it in Settings under Behavior, then trigger it with `g` followed by `s`; turning it off restores mpv's normal subtitle selection binding.
- Single-key shortcuts always take priority over multi-key sequences, and any conflicting sequence is disabled with a warning instead of misbehaving.
- **Subtitle Sidebar Selection & Copy**: You can now select dialogue across multiple subtitle sidebar rows and copy it, without timestamps, using Ctrl/Cmd+C or the Copy button, without seeking or mining a card.
- **Jimaku Live Action Search**: The Jimaku modal has separate Anime and Live Action tabs (switch with Arrow Left/Right) so you can search Jimaku's live-action subtitle catalogue directly.
- **Live-Action TMDB Library**:
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
- Titles AniList can't match are looked up on TMDB automatically when the parsed filename matches a title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
- Release builds already include a TMDB key; if you run from source, set `tmdb.apiKey` (or `tmdb.apiKeyCommand`) yourself.
- **YouTube Library Kind**:
- YouTube channels are now their own Library media kind, with new All Titles, Anime, and YouTube filters. Existing channel entries migrate automatically with viewing history and manual video assignments intact.
- Channels stay out of AniList matching, season repair, and duplicate recommendations, and can't be merged or moved into an anime entry.
- **YouTube Browser Window**:
- Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts.
- Clicking a video plays it in mpv instead of on the page.
- Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist.
- When a queued video starts, it gets the same Japanese subtitle setup.
- If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes.
- **Whisper Subtitles for YouTube**:
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`.
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting.
- Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit.
- **Mouse and Scroll Wheel Bindings**:
- `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor.
- Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist.
- Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before.
- SubMiner's own bindings and right-click pause still win 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`.
- Session help opened from the tray with no video loaded is still read-only.
### Changed
- **Bundled Bun Runtime**: Every SubMiner launcher, installed or downloaded, now runs on the Bun runtime bundled with the app instead of a system-wide Bun install. Recognized legacy launchers migrate automatically, Windows users get a new `subminer.cmd` download, and first-run setup now shows a single optional launcher control with runtime repair guidance only when something actually needs it.
- **Compressed Incremental Sync**: Cross-machine sync between compatible macOS/Linux machines now transfers only what changed, compressed, using a cached snapshot from the last sync to cut traffic further. Machines without a compatible rsync (including Windows) fall back to compressed scp automatically, older peers keep working, and transfers now time out after 30 minutes instead of hanging indefinitely.
- **Smaller Install Size**: Installers and the unpacked app are smaller after dropping demo media, source maps, TypeScript sources, test fixtures, and unused binaries, and sharing one Japanese UI font across windows. Release builds now publish a package-size comparison against the previous release.
- **Stats Server Request Safety**: The stats server, including the in-app stats overlay which now loads through it, only accepts requests from the local machine and requires a JSON content type for anything that changes data. If you were exposing the dashboard through a reverse proxy or Tailscale Serve, that's no longer supported, and any script posting to the stats API needs to send `Content-Type: application/json`.
- **Yomitan Updated**: Bundled Yomitan is updated to upstream 26.9.8, adding historical Japanese kana transformations and Ukrainian language support, plus improvements to Anki duplicate search and audio retrieval.
- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release.
### Fixed
- **Jellyfin**:
- Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
- Casting now honors your configured mpv executable path, so playback works and portable plugins are detected correctly when mpv isn't on PATH.
- The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode.
- Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media".
- Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically.
- **Jellyfin Subtitles**:
- The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track.
- Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles.
- Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically.
- If one subtitle track fails to download, the other tracks are still selected.
- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default.
- **Media Timing Review**:
- Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden.
- The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible.
- On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams.
- **Hyprland Fullscreen**:
- The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before.
- With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen.
- **mpv Key Bindings in the Overlay**:
- mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus.
- They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects.
- **YouTube Japanese Subtitles**:
- Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track.
- If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp.
- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window.
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error.
- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories.
- **Anki & Mining**:
- Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand.
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
- Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player.
- Word-card sentence furigana now stays in sync with the full stats-search context and expanded timing-review selections, and clears stale readings automatically if regeneration fails.
### Docs
- **Settings**:
- AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime.
- Settings marked as applying live now correctly avoid showing a restart warning, and mixed saves apply the live parts immediately while listing only the sections that actually need a restart.
- **Overlay**:
- Clicking a subtitle sidebar cue no longer leaves Space bound to seeking back to it; Enter still seeks the focused cue, and Space keeps whatever playback action you've configured.
- Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates.
- Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down.
- **Stats**:
- Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills.
- Stats server port conflicts now surface as a status notification instead of crashing SubMiner, and startup/shutdown are more robust: concurrent startup requests share one attempt, stopping a background instance no longer disconnects an open dashboard, and shutdown no longer waits indefinitely on active requests.
- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero.
- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet.
- **Docs Site Rewrite**:
- Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables.
- The configuration reference now gives each config block a short explanation and a table of keys and defaults.
- Pages that no longer matched how SubMiner works have been corrected.
- **Arch Linux Install**:
- The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`.
- The README's requirements and quick start now match the installation guide.
## What's Changed
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
- build(release): reduce package size and report release sizes by @ksyasuda in #244
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
- fix(jellyfin): support modern authentication by @ksyasuda in #264
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
## New Contributors
- @aalhendi made their first contribution in #254
- 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
## Installation
See the README and docs/installation guide for full setup steps.
See the [installation guide](https://docs.subminer.moe/installation) for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- Linux: `SubMiner-*.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
+9
View File
@@ -291,6 +291,14 @@ local ctx = {
actionType = "mpv-command",
command = { "sub-seek", -1 },
},
{
key = {
code = "WHEEL_UP",
modifiers = { "ctrl" },
},
actionType = "mpv-command",
command = { "add", "sub-scale", 0.1 },
},
{
key = {
code = "KeyW",
@@ -386,6 +394,7 @@ local expected_mpv_bindings = {
{ keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
{ keys = "Ctrl+WHEEL_UP", command = { "add", "sub-scale", 0.1 } },
}
for _, expected in ipairs(expected_mpv_bindings) do
+3 -1
View File
@@ -212,12 +212,13 @@ test('compileSessionBindings resolves CommandOrControl in DOM key strings per pl
);
});
test('compileSessionBindings supports mpv mouse button keybindings', () => {
test('compileSessionBindings supports mpv mouse button and wheel keybindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('MBTN_BACK', ['sub-seek', -1]),
createKeybinding('Shift+MBTN_FORWARD', ['sub-seek', 1]),
createKeybinding('Ctrl+WHEEL_UP', ['add', 'sub-scale', 0.1]),
],
platform: 'win32',
});
@@ -232,6 +233,7 @@ test('compileSessionBindings supports mpv mouse button keybindings', () => {
[
{ code: 'MBTN_BACK', modifiers: [], command: ['sub-seek', -1] },
{ code: 'MBTN_FORWARD', modifiers: ['shift'], command: ['sub-seek', 1] },
{ code: 'WHEEL_UP', modifiers: ['ctrl'], command: ['add', 'sub-scale', 0.1] },
],
);
});
+9 -5
View File
@@ -34,12 +34,16 @@ type DraftBinding = {
};
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
const MPV_MOUSE_BUTTON_CODES = new Set([
const MPV_MOUSE_CODES = new Set([
'MBTN_LEFT',
'MBTN_MID',
'MBTN_RIGHT',
'MBTN_BACK',
'MBTN_FORWARD',
'WHEEL_UP',
'WHEEL_DOWN',
'WHEEL_LEFT',
'WHEEL_RIGHT',
]);
const SESSION_SHORTCUT_ACTIONS: Array<{
@@ -82,7 +86,7 @@ function isValidCommandEntry(value: unknown): value is string | number {
function normalizeCodeToken(
token: string,
options: { allowMouseButtons?: boolean } = {},
options: { allowMouseInput?: boolean } = {},
): string | null {
const normalized = token.trim();
if (!normalized) return null;
@@ -93,9 +97,9 @@ function normalizeCodeToken(
.map((letter) => `Key${letter.toUpperCase()}`)
.join('-');
}
if (options.allowMouseButtons === true) {
if (options.allowMouseInput === true) {
const normalizedMouse = normalized.toUpperCase();
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
if (MPV_MOUSE_CODES.has(normalizedMouse)) {
return normalizedMouse;
}
}
@@ -270,7 +274,7 @@ export function parseSessionBindingKey(
};
}
const code = normalizeCodeToken(keyToken, { allowMouseButtons: true });
const code = normalizeCodeToken(keyToken, { allowMouseInput: true });
if (!code) {
return {
key: null,
+2
View File
@@ -5354,6 +5354,8 @@ const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSession
logWarn: (message) => logger.warn(message),
onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
onMpvInputBindingsChanged: () =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged),
onWarning: (warning) => {
if (warning.kind !== 'conflict') return;
overlayNotificationsRuntime.showOverlayNotification({
@@ -71,6 +71,45 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
}
});
test('mpv input binding discovery notifies the overlay when the native key set changes', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-native-keys-'));
let nativeKeys: unknown = [{ key: '9', cmd: 'add volume -5', priority: 25 }];
let notifications = 0;
const client = {
connected: false,
send: () => {},
requestProperty: async () => nativeKeys,
};
const runtime = createSessionBindingsRuntime({
configDir: root,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
getMpvClient: () => client,
setSessionBindings: () => {},
setSessionBindingsInitialized: () => {},
logWarn: () => {},
onMpvInputBindingsChanged: () => {
notifications += 1;
},
});
try {
runtime.persistSessionBindings([]);
await runtime.refreshMpvSessionBindings();
assert.equal(notifications, 0, 'nothing to announce before mpv connects');
client.connected = true;
await runtime.refreshMpvSessionBindings();
assert.equal(notifications, 1, 'first discovery after connecting must reach the overlay');
await runtime.refreshMpvSessionBindings();
assert.equal(notifications, 1, 'unchanged discovery must not create a refresh loop');
nativeKeys = [{ key: '0', cmd: 'ignore', priority: 25 }];
await runtime.refreshMpvSessionBindings();
assert.equal(notifications, 2, 'ignored keys still change what the overlay may forward');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
const sequence: CompiledSessionBinding = {
+13 -1
View File
@@ -25,6 +25,10 @@ export interface SessionBindingsRuntimeDeps {
setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void;
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
// Fires when mpv's own key bindings change, including the first discovery after
// connecting. The overlay's imported mpv keys depend on them even when the
// compiled session bindings stay identical.
onMpvInputBindingsChanged?: () => void;
onWarning?: (warning: SessionBindingWarning) => void;
}
@@ -41,6 +45,7 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
let nativeSnapshot: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
keys: string[];
signature: string;
} | null = null;
let pending: {
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
@@ -135,8 +140,15 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
try {
const raw = await client.requestProperty('input-bindings');
if (client !== deps.getMpvClient() || !client.connected) return;
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
const signature = JSON.stringify(parseMpvInputBindingKeys(raw));
const changed = nativeSnapshot?.client !== client || nativeSnapshot.signature !== signature;
nativeSnapshot = {
client,
keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }),
signature,
};
publishBindings();
if (changed) deps.onMpvInputBindingsChanged?.();
} catch {
// Keep the last successful snapshot if discovery is temporarily unavailable.
}
@@ -68,61 +68,72 @@ test('preparation failures keep their exit code and never continue to the launch
assert.match(content, /set "ELECTRON_RUN_AS_NODE="/);
});
test('Windows bootstrap prepares once and forwards metacharacter arguments', async (t) => {
if (process.platform !== 'win32') return;
// Cold and cached launches each allow 15 seconds, plus executable copies during setup.
test(
'Windows bootstrap prepares once and forwards metacharacter arguments',
{ timeout: 45_000 },
async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed & App 100% !');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const resourcesPath = path.join(appDirectory, 'resources');
const launcherDirectory = path.join(resourcesPath, 'launcher');
const localAppData = path.join(root, 'Local App Data');
const bootstrapPath = path.join(root, 'subminer.cmd');
const version = '1.2.3-test';
const cachedBunPath = path.join(localAppData, 'SubMiner', 'launcher-runtime', version, 'bun.exe');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(path.join(launcherDirectory, 'version'), version);
fs.writeFileSync(
path.join(launcherDirectory, 'subminer.js'),
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
`const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`,
);
fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath));
const root = workspace(t);
const appDirectory = path.join(root, 'Installed & App 100% !');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const resourcesPath = path.join(appDirectory, 'resources');
const launcherDirectory = path.join(resourcesPath, 'launcher');
const localAppData = path.join(root, 'Local App Data');
const bootstrapPath = path.join(root, 'subminer.cmd');
const version = '1.2.3-test';
const cachedBunPath = path.join(
localAppData,
'SubMiner',
'launcher-runtime',
version,
'bun.exe',
);
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(path.join(launcherDirectory, 'version'), version);
fs.writeFileSync(
path.join(launcherDirectory, 'subminer.js'),
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
);
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
`const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`,
);
fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath));
const args = [
'spaces here',
'100%',
'p%TEMP%q',
'bang!',
'a&b',
'x|y',
'<left>',
'caret^',
'say "hi"',
'日本語',
];
const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData };
const first = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(first.exitCode, 0, first.stderr);
assert.ok(fs.existsSync(cachedBunPath));
const firstPayload: unknown = JSON.parse(first.stdout);
assert.deepEqual(firstPayload, {
args,
app: appPath,
resources: resourcesPath,
managed: '1',
});
const args = [
'spaces here',
'100%',
'p%TEMP%q',
'bang!',
'a&b',
'x|y',
'<left>',
'caret^',
'say "hi"',
'日本語',
];
const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData };
const first = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(first.exitCode, 0, first.stderr);
assert.ok(fs.existsSync(cachedBunPath));
const firstPayload: unknown = JSON.parse(first.stdout);
assert.deepEqual(firstPayload, {
args,
app: appPath,
resources: resourcesPath,
managed: '1',
});
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
"throw new Error('cached launch should not prepare');",
);
const second = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(second.exitCode, 0, second.stderr);
const secondPayload: unknown = JSON.parse(second.stdout);
assert.deepEqual(secondPayload, firstPayload);
});
fs.writeFileSync(
path.join(launcherDirectory, 'prepare.cjs'),
"throw new Error('cached launch should not prepare');",
);
const second = await getRunCommand({})(bootstrapPath, args, { env });
assert.equal(second.exitCode, 0, second.stderr);
const secondPayload: unknown = JSON.parse(second.stdout);
assert.deepEqual(secondPayload, firstPayload);
},
);
+3
View File
@@ -624,6 +624,9 @@ const electronAPI: ElectronAPI = {
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
);
},
onMpvInputBindingsChanged: (callback: () => void) => {
ipcRenderer.on(IPC_CHANNELS.event.mpvInputBindingsChanged, () => callback());
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.configHotReload,
+122 -1
View File
@@ -342,8 +342,9 @@ function installKeyboardTestGlobals() {
altKey?: boolean;
shiftKey?: boolean;
target?: unknown;
type?: 'mousedown' | 'mouseup';
}): void {
const listeners = documentListeners.get('mousedown') ?? [];
const listeners = documentListeners.get(event.type ?? 'mousedown') ?? [];
const mouseEvent = {
button: event.button,
ctrlKey: event.ctrlKey ?? false,
@@ -360,6 +361,36 @@ function installKeyboardTestGlobals() {
const dispatchDocumentMouseDown = dispatchMousedown;
function dispatchWheel(event: {
deltaY: number;
deltaX?: number;
ctrlKey?: boolean;
shiftKey?: boolean;
target?: unknown;
}): boolean {
let prevented = false;
const wheelEvent = {
deltaX: event.deltaX ?? 0,
deltaY: event.deltaY,
deltaMode: 0,
ctrlKey: event.ctrlKey ?? false,
metaKey: false,
altKey: false,
shiftKey: event.shiftKey ?? false,
get defaultPrevented() {
return prevented;
},
preventDefault: () => {
prevented = true;
},
target: event.target ?? null,
};
for (const listener of documentListeners.get('wheel') ?? []) {
listener(wheelEvent);
}
return prevented;
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
@@ -417,6 +448,7 @@ function installKeyboardTestGlobals() {
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchMousedown,
dispatchWheel,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -1102,6 +1134,95 @@ test('configured mouse button keybinding dispatches through overlay mouse handli
}
});
test('configured wheel keybinding fires once per whole notch', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Ctrl+WHEEL_UP',
key: { code: 'WHEEL_UP', modifiers: ['ctrl'] },
actionType: 'mpv-command',
command: ['add', 'sub-scale', 0.1],
},
] as never);
assert.equal(testGlobals.dispatchWheel({ deltaY: -240, ctrlKey: true }), true);
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -120 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
]);
} finally {
testGlobals.restore();
}
});
test('imported mpv wheel bindings forward from the overlay but not over modals', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['WHEEL_UP', 'WHEEL_DOWN'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchWheel({ deltaY: 120 });
testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() });
assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]);
} finally {
testGlobals.restore();
}
});
test('imported mpv mouse buttons forward only after SubMiner mouse handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['MBTN_LEFT_DBL', 'MBTN_RIGHT', 'MBTN_BACK'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'MBTN_BACK',
key: { code: 'MBTN_BACK', modifiers: [] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchMousedown({ button: 0 });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
testGlobals.dispatchMousedown({ button: 0, target: testGlobals.createInteractiveTarget() });
testGlobals.dispatchMousedown({ button: 2 });
testGlobals.dispatchMousedown({ button: 3 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
['sub-seek', -1],
['cycle', 'pause'],
]);
} finally {
testGlobals.restore();
}
});
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+71 -31
View File
@@ -1,6 +1,7 @@
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/mpv-input-bindings';
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
@@ -43,13 +44,6 @@ export function createKeyboardHandlers(
const CHORD_TIMEOUT_MS = 1000;
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const MOUSE_BUTTON_CODE_BY_BUTTON: Record<number, string> = {
0: 'MBTN_LEFT',
1: 'MBTN_MID',
2: 'MBTN_RIGHT',
3: 'MBTN_BACK',
4: 'MBTN_FORWARD',
};
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false;
@@ -58,6 +52,8 @@ export function createKeyboardHandlers(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
// Fractional wheel notches (trackpads) carried toward the next configured wheel binding.
let pendingWheelBinding: { key: string; notches: number } | null = null;
let mpvInputForwardingListenersInstalled = false;
let keyboardConfigLoaded = false;
const importedMpvBindings = createMpvInputForwarding({
@@ -89,20 +85,8 @@ export function createKeyboardHandlers(
return false;
}
function keyEventToString(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
parts.push(e.code);
return parts.join('+');
}
function mouseEventToString(e: MouseEvent): string | null {
const code = MOUSE_BUTTON_CODE_BY_BUTTON[e.button];
if (!code) return null;
// Builds the session binding map key (`Ctrl+Shift+KeyR`) for an input event.
function inputEventToString(e: KeyboardEvent | MouseEvent, code: string): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
@@ -112,6 +96,60 @@ export function createKeyboardHandlers(
return parts.join('+');
}
function keyEventToString(e: KeyboardEvent): string {
return inputEventToString(e, e.code);
}
function mouseEventToString(e: MouseEvent): string | null {
const code = MPV_MOUSE_BUTTON_BY_BUTTON[e.button];
return code ? inputEventToString(e, code) : null;
}
// Overlay UI that handles its own mouse input (menus, sidebar, notifications, controls).
function isOverlayControlTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest(
'.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea',
),
);
}
// Imported mpv bindings only run when no SubMiner UI could be the input's target.
function canForwardToMpv(target: EventTarget | null): boolean {
return (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(target)
);
}
// Scrolling over subtitles still reaches mpv; only scrollable overlay UI keeps the wheel.
function handleWheel(e: WheelEvent): void {
if (isOverlayControlTarget(e.target)) return;
const scroll = wheelEventToMpvWheel(e);
if (!scroll) return;
const wheelString = inputEventToString(e, scroll.key);
const binding = ctx.state.sessionBindingMap.get(wheelString);
if (binding) {
e.preventDefault();
// SubMiner actions are not scalable, so fire once per whole notch.
const notches =
(pendingWheelBinding?.key === wheelString ? pendingWheelBinding.notches : 0) +
scroll.notches;
const presses = Math.floor(notches + 1e-6); // absorb float drift from summed deltas
pendingWheelBinding = { key: wheelString, notches: Math.max(0, notches - presses) };
for (let i = 0; i < presses; i++) dispatchSessionBinding(binding);
return;
}
pendingWheelBinding = null;
if (keyboardConfigLoaded) importedMpvBindings.wheel(e);
}
function updateConfiguredShortcuts(
shortcuts: Required<ShortcutsConfig>,
statsToggleKey?: string,
@@ -1290,16 +1328,7 @@ export function createKeyboardHandlers(
e.preventDefault();
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(e.target)
)
importedMpvBindings.keydown(e);
if (canForwardToMpv(e.target)) importedMpvBindings.keydown(e);
});
document.addEventListener('mousedown', (e: MouseEvent) => {
@@ -1319,8 +1348,16 @@ export function createKeyboardHandlers(
.finally(() => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
});
return;
}
if (canForwardToMpv(e.target) && !isOverlayControlTarget(e.target)) {
importedMpvBindings.mousedown(e);
}
});
document.addEventListener('mouseup', importedMpvBindings.mouseup, true);
document.addEventListener('wheel', handleWheel, { passive: false });
document.addEventListener('contextmenu', (e: Event) => {
if (!isInteractiveTarget(e.target)) {
@@ -1335,6 +1372,9 @@ export function createKeyboardHandlers(
setupMpvInputForwarding,
refreshConfiguredShortcuts,
updateSessionBindings,
refreshMpvInputBindings: () => {
void importedMpvBindings.refresh();
},
syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility,
@@ -59,6 +59,71 @@ test('configured and disabled keys, handled input, and unknown keys are not forw
assert.deepEqual(commands, []);
});
test('imported wheel bindings forward as scaled keypresses unless SubMiner claims them', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({
keys: ['WHEEL_UP', 'WHEEL_DOWN', 'shift+WHEEL_UP'],
blockedKeys: [{ code: 'WHEEL_DOWN', modifiers: [] }],
}),
send: (command) => commands.push(command),
});
await forwarding.refresh();
const wheelEvent = {
deltaX: 0,
deltaY: -60,
deltaMode: 0,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
defaultPrevented: false,
preventDefault: () => {},
};
assert.equal(forwarding.wheel(wheelEvent), true);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaY: -120, shiftKey: true }), true);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaY: 120 }), false);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaX: 120, deltaY: 0 }), false);
assert.equal(forwarding.wheel({ ...wheelEvent, defaultPrevented: true }), false);
assert.deepEqual(commands, [
['keypress', 'WHEEL_UP', 0.5],
['keypress', 'shift+WHEEL_UP', 1],
]);
});
test('mouse buttons forward as held keys when mpv binds the button or its double-click', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({
keys: ['MBTN_LEFT_DBL', 'MBTN_BACK', 'MBTN_FORWARD'],
blockedKeys: [{ code: 'MBTN_FORWARD', modifiers: [] }],
}),
send: (command) => commands.push(command),
});
await forwarding.refresh();
const mouseEvent = {
button: 0,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
defaultPrevented: false,
preventDefault: () => {},
};
assert.equal(forwarding.mousedown(mouseEvent), true);
forwarding.mouseup(mouseEvent);
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 3 }), true);
forwarding.releaseAll();
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 4 }), false);
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 1 }), false);
assert.deepEqual(commands, [
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
['keydown', 'MBTN_BACK'],
['keyup', 'MBTN_BACK'],
]);
});
test('refresh discards stale responses and coalesces concurrent requests', async () => {
let resolveFirst: (snapshot: MpvInputBindingsSnapshot) => void = () => {};
let requests = 0;
+80 -13
View File
@@ -1,8 +1,19 @@
import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings';
import {
MPV_MOUSE_BUTTON_BY_BUTTON,
keyboardEventToMpvKey,
normalizeMpvInputKey,
wheelEventToMpvWheel,
} from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
type ModifierState = Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'>;
type ForwardedKeyEvent = Parameters<typeof keyboardEventToMpvKey>[0] &
Pick<KeyboardEvent, 'code' | 'repeat' | 'defaultPrevented' | 'preventDefault'>;
type ForwardedWheelEvent = Parameters<typeof wheelEventToMpvWheel>[0] &
ModifierState &
Pick<WheelEvent, 'defaultPrevented' | 'preventDefault'>;
type ForwardedMouseEvent = ModifierState &
Pick<MouseEvent, 'button' | 'defaultPrevented' | 'preventDefault'>;
export function createMpvInputForwarding(deps: {
load: () => Promise<MpvInputBindingsSnapshot>;
@@ -47,6 +58,30 @@ export function createMpvInputForwarding(deps: {
return pending;
}
// Keys claimed by SubMiner's configured keybindings stay with SubMiner.
function isBlocked(code: string, event: ModifierState): boolean {
return blockedKeys.some(
({ code: blockedCode, modifiers }) =>
blockedCode === code &&
modifiers.includes('ctrl') === event.ctrlKey &&
modifiers.includes('alt') === event.altKey &&
modifiers.includes('shift') === event.shiftKey &&
modifiers.includes('meta') === event.metaKey,
);
}
function modifiedMpvKey(event: ModifierState, key: string): string | null {
return normalizeMpvInputKey(
[
...(event.ctrlKey ? ['ctrl'] : []),
...(event.altKey ? ['alt'] : []),
...(event.shiftKey ? ['shift'] : []),
...(event.metaKey ? ['meta'] : []),
key,
].join('+'),
);
}
function keydown(event: ForwardedKeyEvent): boolean {
if (disposed || event.defaultPrevented) return false;
if (heldKeys.has(event.code)) {
@@ -54,17 +89,7 @@ export function createMpvInputForwarding(deps: {
return true;
}
if (event.repeat || event.code.startsWith('Numpad')) return false;
if (
blockedKeys.some(
({ code, modifiers }) =>
code === event.code &&
modifiers.includes('ctrl') === event.ctrlKey &&
modifiers.includes('alt') === event.altKey &&
modifiers.includes('shift') === event.shiftKey &&
modifiers.includes('meta') === event.metaKey,
)
)
return false;
if (isBlocked(event.code, event)) return false;
const key = keyboardEventToMpvKey(event);
if (!key || !keys.has(key)) return false;
heldKeys.set(event.code, key);
@@ -81,11 +106,53 @@ export function createMpvInputForwarding(deps: {
event.preventDefault();
}
// Wheel scrolls are single events, so they go through mpv's keypress with the notch
// count as scale, matching how mpv handles precise scrolling natively.
function wheel(event: ForwardedWheelEvent): boolean {
if (disposed || event.defaultPrevented) return false;
const scroll = wheelEventToMpvWheel(event);
if (!scroll || isBlocked(scroll.key, event)) return false;
const key = modifiedMpvKey(event, scroll.key);
if (!key || !keys.has(key)) return false;
deps.send(['keypress', key, scroll.notches]);
event.preventDefault();
return true;
}
// Buttons go through keydown/keyup so held-button bindings and mpv's own double-click
// detection (MBTN_LEFT_DBL) work. A button is forwarded when mpv binds it or its
// double-click.
function mousedown(event: ForwardedMouseEvent): boolean {
if (disposed || event.defaultPrevented) return false;
const heldId = `mouse:${event.button}`;
if (heldKeys.has(heldId)) {
event.preventDefault();
return true;
}
const button = MPV_MOUSE_BUTTON_BY_BUTTON[event.button];
if (!button || isBlocked(button, event)) return false;
const key = modifiedMpvKey(event, button);
if (!key || (!keys.has(key) && !keys.has(`${key}_DBL`))) return false;
heldKeys.set(heldId, key);
deps.send(['keydown', key]);
event.preventDefault();
return true;
}
function mouseup(event: Pick<MouseEvent, 'button' | 'preventDefault'>): void {
const heldId = `mouse:${event.button}`;
const key = heldKeys.get(heldId);
if (!key) return;
heldKeys.delete(heldId);
deps.send(['keyup', key]);
event.preventDefault();
}
function dispose(): void {
disposed = true;
keys.clear();
releaseAll();
}
return { refresh, keydown, keyup, releaseAll, dispose };
return { refresh, keydown, keyup, wheel, mousedown, mouseup, releaseAll, dispose };
}
@@ -71,6 +71,10 @@ const KEY_NAME_MAP: Record<string, string> = {
MBTN_RIGHT: 'Mouse Right',
MBTN_BACK: 'Mouse Back',
MBTN_FORWARD: 'Mouse Forward',
WHEEL_UP: 'Wheel Up',
WHEEL_DOWN: 'Wheel Down',
WHEEL_LEFT: 'Wheel Left',
WHEEL_RIGHT: 'Wheel Right',
};
function normalizeKeyToken(token: string): string {
+3
View File
@@ -779,6 +779,9 @@ async function init(): Promise<void> {
});
});
// Subscribe before the initial discovery: mpv can connect while it is in flight,
// and a missed change event leaves the imported mpv keys empty.
window.electronAPI.onMpvInputBindingsChanged(keyboardHandlers.refreshMpvInputBindings);
await keyboardHandlers.setupMpvInputForwarding();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
+16
View File
@@ -6,6 +6,7 @@ import {
createMpvKeybindingRows,
keyboardEventToConfigKey,
mouseEventToConfigKey,
wheelEventToConfigKey,
} from './key-input';
test('keyboardEventToConfigKey formats Electron accelerators from learned input', () => {
@@ -93,6 +94,21 @@ test('mouseEventToConfigKey formats mpv mouse buttons from learned input', () =>
);
});
test('wheelEventToConfigKey formats mpv wheel keys only for mpv keybindings', () => {
const wheel = {
deltaX: 0,
deltaY: -120,
deltaMode: 0,
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
};
assert.equal(wheelEventToConfigKey(wheel, 'dom-code'), 'Ctrl+WHEEL_UP');
assert.equal(wheelEventToConfigKey({ ...wheel, deltaY: 0 }, 'dom-code'), null);
assert.equal(wheelEventToConfigKey(wheel, 'accelerator'), null);
});
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
const rows = createMpvKeybindingRows(defaults, []);
+19 -13
View File
@@ -1,4 +1,5 @@
import type { Keybinding } from '../types/runtime';
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../shared/mpv-input-bindings';
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key';
@@ -19,6 +20,9 @@ export interface MouseInputLike {
metaKey: boolean;
}
export type WheelInputLike = Omit<MouseInputLike, 'button'> &
Parameters<typeof wheelEventToMpvWheel>[0];
export interface MpvKeybindingRow {
defaultKey: string;
key: string;
@@ -87,14 +91,6 @@ const MPV_KEY_BY_CODE: Record<string, string> = {
Tab: 'TAB',
};
const MPV_MOUSE_BUTTON_BY_BUTTON: Record<number, string> = {
0: 'MBTN_LEFT',
1: 'MBTN_MID',
2: 'MBTN_RIGHT',
3: 'MBTN_BACK',
4: 'MBTN_FORWARD',
};
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
@@ -169,12 +165,22 @@ export function keyboardEventToConfigKey(
}
export function mouseEventToConfigKey(input: MouseInputLike, mode: KeyInputMode): string | null {
if (mode !== 'dom-code') {
return null;
}
const key = MPV_MOUSE_BUTTON_BY_BUTTON[input.button];
if (!key) {
return key ? mouseInputToConfigKey(input, key, mode) : null;
}
export function wheelEventToConfigKey(input: WheelInputLike, mode: KeyInputMode): string | null {
const key = wheelEventToMpvWheel(input)?.key;
return key ? mouseInputToConfigKey(input, key, mode) : null;
}
// Mouse input is only bindable through mpv keybindings, which use DOM-code keys.
function mouseInputToConfigKey(
input: Omit<MouseInputLike, 'button'>,
key: string,
mode: KeyInputMode,
): string | null {
if (mode !== 'dom-code') {
return null;
}
@@ -6,6 +6,7 @@ import {
keyboardEventToConfigKey,
mouseEventToConfigKey,
parseMpvCommandText,
wheelEventToConfigKey,
type KeyInputMode,
type MpvKeybindingRow,
} from './key-input';
@@ -43,11 +44,13 @@ function startKeyLearning(
let onKeyDown: (event: KeyboardEvent) => void;
let onBlur: () => void;
let onMouseDown: (event: MouseEvent) => void;
let onWheel: (event: WheelEvent) => void;
const stop = (): void => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('mousedown', onMouseDown, true);
window.removeEventListener('wheel', onWheel, true);
button.classList.remove('learning');
if (button.textContent === 'Press Keys...') {
button.textContent = previousText;
@@ -85,9 +88,19 @@ function startKeyLearning(
}
};
onWheel = (event: WheelEvent): void => {
const next = wheelEventToConfigKey(event, mode);
if (!next) return;
event.preventDefault();
event.stopPropagation();
stop();
onValue(next);
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('blur', onBlur, true);
window.addEventListener('mousedown', onMouseDown, true);
window.addEventListener('wheel', onWheel, { capture: true, passive: false });
activeKeyLearningStop = stop;
}
+1
View File
@@ -181,6 +181,7 @@ export const IPC_CHANNELS = {
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
sessionBindingsChanged: 'session-bindings:changed',
mpvInputBindingsChanged: 'mpv-input-bindings:changed',
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
+27 -3
View File
@@ -4,16 +4,19 @@ import {
keyboardEventToMpvKey,
normalizeMpvInputKey,
parseMpvInputBindingKeys,
wheelEventToMpvWheel,
} from './mpv-input-bindings';
test('mpv discovery validates entries and excludes inactive, mouse, sequence, and SubMiner keys', () => {
test('mpv discovery validates entries and excludes inactive, sequence, and SubMiner keys', () => {
assert.deepEqual(
parseMpvInputBindingKeys([
{ key: 'r', cmd: 'script-binding replay/run', priority: 1, owner: 'replay' },
{ key: 'r', cmd: 'show-text duplicate', priority: 0 },
{ key: 'Ctrl+A', cmd: 'show-text shifted', priority: 1 },
{ key: 'g-g', cmd: 'seek 0', priority: 1 },
{ key: 'MBTN_LEFT', cmd: 'cycle pause', priority: 1 },
{ key: 'MBTN_LEFT_DBL', cmd: 'cycle fullscreen', priority: 1 },
{ key: 'MOUSE_MOVE', cmd: 'script-binding osc/move', priority: 1 },
{ key: 'Shift+WHEEL_UP', cmd: 'add volume 2', priority: 1 },
{ key: 'q', cmd: 'quit', priority: -1 },
{ key: 's', cmd: 'screenshot', priority: 1 },
{ key: 's', cmd: 'script-binding subminer/session', priority: 5, owner: 'subminer' },
@@ -22,7 +25,7 @@ test('mpv discovery validates entries and excludes inactive, mouse, sequence, an
{ key: 'z', cmd: 5, priority: 1 },
null,
]),
['r', 'ctrl+A'],
['r', 'ctrl+A', 'MBTN_LEFT_DBL', 'shift+WHEEL_UP'],
);
assert.deepEqual(parseMpvInputBindingKeys({ key: 'r' }), []);
});
@@ -33,6 +36,27 @@ test('mpv keys retain printable characters and normalize modifiers', () => {
assert.equal(normalizeMpvInputKey('Shift+LEFT'), 'shift+LEFT');
assert.equal(normalizeMpvInputKey('F12'), 'F12');
assert.equal(normalizeMpvInputKey('UNMAPPED'), null);
assert.equal(normalizeMpvInputKey('Ctrl+WHEEL_DOWN'), 'ctrl+WHEEL_DOWN');
});
test('wheel conversion picks the dominant axis and reports mpv notch scale', () => {
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 0, deltaY: -240, deltaMode: 0 }), {
key: 'WHEEL_UP',
notches: 2,
});
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 3, deltaY: 30, deltaMode: 0 }), {
key: 'WHEEL_DOWN',
notches: 0.25,
});
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 120, deltaY: 0, deltaMode: 0 }), {
key: 'WHEEL_RIGHT',
notches: 1,
});
assert.deepEqual(wheelEventToMpvWheel({ deltaX: -3, deltaY: 0, deltaMode: 1 }), {
key: 'WHEEL_LEFT',
notches: 1,
});
assert.equal(wheelEventToMpvWheel({ deltaX: 0, deltaY: 0, deltaMode: 0 }), null);
});
test('keyboard conversion respects layout characters and skips composition and AltGr', () => {
+44 -3
View File
@@ -16,13 +16,33 @@ const SPECIAL_KEYS: Record<string, string> = {
ArrowUp: 'UP',
ArrowDown: 'DOWN',
};
const MPV_SPECIAL_KEYS = new Set(Object.values(SPECIAL_KEYS));
const MPV_WHEEL_KEYS = ['WHEEL_UP', 'WHEEL_DOWN', 'WHEEL_LEFT', 'WHEEL_RIGHT'] as const;
export type MpvWheelKey = (typeof MPV_WHEEL_KEYS)[number];
// DOM MouseEvent.button to mpv's mouse button names.
export const MPV_MOUSE_BUTTON_BY_BUTTON: Readonly<Record<number, string>> = {
0: 'MBTN_LEFT',
1: 'MBTN_MID',
2: 'MBTN_RIGHT',
3: 'MBTN_BACK',
4: 'MBTN_FORWARD',
};
// mpv synthesizes these from two quick presses of the base button.
const MPV_DOUBLE_CLICK_KEYS = ['MBTN_LEFT_DBL', 'MBTN_MID_DBL', 'MBTN_RIGHT_DBL'];
const MPV_SPECIAL_KEYS = new Set<string>([
...Object.values(SPECIAL_KEYS),
...MPV_WHEEL_KEYS,
...Object.values(MPV_MOUSE_BUTTON_BY_BUTTON),
...MPV_DOUBLE_CLICK_KEYS,
]);
// Chromium reports 120 px per wheel notch on both Wayland and X11.
const WHEEL_NOTCH_PIXELS = 120;
const WHEEL_NOTCH_LINES = 3;
// Leading command flags accepted by mpv's input/cmd.c, before the command name.
const MPV_COMMAND_PREFIXES =
/^(?:(?:no-osd|osd-bar|osd-msg|osd-msg-bar|osd-auto|expand-properties|raw|repeatable|nonrepeatable|nonscalable|async|sync)\s+)+/;
// Only single keyboard strokes are imported. Mouse input and sequences need
// their own focus and conflict rules before they can be forwarded safely.
// Single keyboard strokes, mouse buttons, and wheel scrolls are imported. Key sequences
// and pointer motion (MOUSE_MOVE) are not.
export function normalizeMpvInputKey(value: string): string | null {
const modifiers = new Set<string>();
let key = value;
@@ -59,6 +79,27 @@ export function keyboardEventToMpvKey(
return normalizeMpvInputKey([...modifiers, key].join('+'));
}
// Maps a DOM wheel event to mpv's wheel key and its notch count, which mpv uses as the
// precise-scroll scale for `keypress <key> <scale>`. Trackpads yield fractional notches.
export function wheelEventToMpvWheel(
event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
): { key: MpvWheelKey; notches: number } | null {
const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX);
const delta = vertical ? event.deltaY : event.deltaX;
if (!Number.isFinite(delta) || delta === 0) return null;
const key: MpvWheelKey = vertical
? delta < 0
? 'WHEEL_UP'
: 'WHEEL_DOWN'
: delta < 0
? 'WHEEL_LEFT'
: 'WHEEL_RIGHT';
// deltaMode: 0 = pixels, 1 = lines, 2 = pages.
const unit =
event.deltaMode === 1 ? WHEEL_NOTCH_LINES : event.deltaMode === 2 ? 1 : WHEEL_NOTCH_PIXELS;
return { key, notches: Math.abs(delta) / unit };
}
export function parseMpvInputBindingKeys(
value: unknown,
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
+2 -2
View File
@@ -82,7 +82,7 @@ test('shouldForceX11MpvBackend honors explicit x11 and auto modes', () => {
});
});
test('applyX11EnvOverrides strips Wayland hints and pins session type to x11', () => {
test('applyX11EnvOverrides strips Wayland hints, keeps Hyprland IPC, and pins session type to x11', () => {
const env = {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
@@ -94,7 +94,7 @@ test('applyX11EnvOverrides strips Wayland hints and pins session type to x11', (
assert.equal(result, env); // mutates in place
assert.equal(result.DISPLAY, ':1');
assert.equal(result.WAYLAND_DISPLAY, undefined);
assert.equal(result.HYPRLAND_INSTANCE_SIGNATURE, undefined);
assert.equal(result.HYPRLAND_INSTANCE_SIGNATURE, 'hypr');
assert.equal(result.SWAYSOCK, undefined);
assert.equal(result.XDG_SESSION_TYPE, 'x11');
});
+5 -1
View File
@@ -89,10 +89,14 @@ export function shouldForceX11MpvBackend(
/**
* Strip Wayland/compositor hints and pin the session type to X11 on the given env
* object (mutates in place and returns it) so a child mpv process picks XWayland.
*
* HYPRLAND_INSTANCE_SIGNATURE is kept: the overlay app inherits this env through the
* mpv plugin, and Hyprland still manages its XWayland windows. Without hyprctl access
* the overlay cannot lift Hyprland's fullscreen input block, so it stays visible but
* unclickable over fullscreen mpv.
*/
export function applyX11EnvOverrides(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
delete env.WAYLAND_DISPLAY;
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
+1
View File
@@ -656,6 +656,7 @@ export interface ElectronAPI {
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
onMpvInputBindingsChanged: (callback: () => void) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}