@@ -55,7 +55,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.

@@ -63,22 +63,12 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
-### 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.
-
-
-

-
-
-
-
### Integrations
| YouTube |
- Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (Ctrl+Alt+C) |
+ Play YouTube URLs with subtitle tracks picked by your language priorities, or choose tracks yourself in the overlay picker (Ctrl+Alt+C). Requires yt-dlp |
| AniList |
@@ -86,19 +76,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
| Jellyfin |
- Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray |
+ Browse your Jellyfin library, or cast to SubMiner from any Jellyfin client. Setup and discovery live in the tray menu |
| Jimaku |
- Search and download Japanese subtitles |
-
-
- | Local Subtitle Generation |
- Generate Japanese subtitles from local audio in a standalone modal (Ctrl+Shift+G), 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. Setup guide |
+ Search and download Japanese subtitles. Requires a free Jimaku API key |
| TsukiHime |
- Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (Ctrl+Shift+T) — no API key, requires xz on your PATH |
+ Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (Ctrl+Shift+T). Requires xz on your PATH |
+
+
+ | Subtitle generation |
+ Transcribe a video's audio into Japanese subtitles locally from the generation modal (Ctrl+Shift+G), 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. Setup guide |
| AniSkip |
@@ -106,7 +96,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
| alass / ffsubsync |
- Manual subtitle retiming — requires alass or ffsubsync on your PATH (optional; subtitle syncing is disabled without them) |
+ Retime a subtitle against the audio or another subtitle track (Ctrl+Alt+S). Requires alass or ffsubsync; set subsync.alass_path or subsync.ffsubsync_path if they are not in /usr/bin |
| WebSocket |
@@ -114,29 +104,29 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
-
-

-
-
---
## 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 |
Platform-specific install commands
@@ -144,9 +134,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
@@ -160,16 +153,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.
@@ -186,6 +179,8 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
paru -S subminer-bin
```
+Includes the AppImage and the `subminer` command.
+
@@ -197,14 +192,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.
@@ -213,14 +201,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**.
+
Windows
-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-.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). A portable `.zip` is also available.
@@ -233,28 +221,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
diff --git a/changes/docs-site-simplify.md b/changes/docs-site-simplify.md
index 542271e8..5ee27e0c 100644
--- a/changes/docs-site-simplify.md
+++ b/changes/docs-site-simplify.md
@@ -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.
diff --git a/changes/mpv-key-forwarding-late-connect.md b/changes/mpv-key-forwarding-late-connect.md
new file mode 100644
index 00000000..2139bc82
--- /dev/null
+++ b/changes/mpv-key-forwarding-late-connect.md
@@ -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.
diff --git a/changes/overlay-scroll-wheel-bindings.md b/changes/overlay-scroll-wheel-bindings.md
new file mode 100644
index 00000000..ab5e4d4d
--- /dev/null
+++ b/changes/overlay-scroll-wheel-bindings.md
@@ -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.
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index 985226e8..0e8269bd 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -268,10 +268,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:
[: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
diff --git a/docs-site/installation.md b/docs-site/installation.md
index 20ac38d5..64d5d879 100644
--- a/docs-site/installation.md
+++ b/docs-site/installation.md
@@ -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)
diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md
index 75a613a8..ed0466c1 100644
--- a/docs-site/shortcuts.md
+++ b/docs-site/shortcuts.md
@@ -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.
diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md
index d5091148..346ecb0c 100644
--- a/docs/architecture/domains.md
+++ b/docs/architecture/domains.md
@@ -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 ` (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`
diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua
index b0f81a5a..dde60e84 100644
--- a/plugin/subminer/session_bindings.lua
+++ b/plugin/subminer/session_bindings.lua
@@ -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 = {
diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua
index 7a117d59..e884c6a6 100644
--- a/scripts/test-plugin-session-bindings.lua
+++ b/scripts/test-plugin-session-bindings.lua
@@ -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
diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts
index 079f2dc3..4141a9c7 100644
--- a/src/core/services/session-bindings.test.ts
+++ b/src/core/services/session-bindings.test.ts
@@ -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] },
],
);
});
diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts
index 01329489..89d17eb7 100644
--- a/src/core/services/session-bindings.ts
+++ b/src/core/services/session-bindings.ts
@@ -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,
diff --git a/src/main.ts b/src/main.ts
index 8c0f7509..1c0e9719 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5540,6 +5540,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({
diff --git a/src/main/runtime/session-bindings-runtime.test.ts b/src/main/runtime/session-bindings-runtime.test.ts
index b6785b00..7868cfbf 100644
--- a/src/main/runtime/session-bindings-runtime.test.ts
+++ b/src/main/runtime/session-bindings-runtime.test.ts
@@ -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 = {
diff --git a/src/main/runtime/session-bindings-runtime.ts b/src/main/runtime/session-bindings-runtime.ts
index 8912d632..e861b76a 100644
--- a/src/main/runtime/session-bindings-runtime.ts
+++ b/src/main/runtime/session-bindings-runtime.ts
@@ -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;
keys: string[];
+ signature: string;
} | null = null;
let pending: {
client: ReturnType;
@@ -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.
}
diff --git a/src/preload.ts b/src/preload.ts
index 0b3e1474..265c54d8 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -662,6 +662,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,
diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts
index d30129fd..3ca8a668 100644
--- a/src/renderer/handlers/keyboard.test.ts
+++ b/src/renderer/handlers/keyboard.test.ts
@@ -343,8 +343,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,
@@ -361,6 +362,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 = {
@@ -418,6 +449,7 @@ function installKeyboardTestGlobals() {
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchMousedown,
+ dispatchWheel,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -1127,6 +1159,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();
diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts
index a9855a87..38d198b9 100644
--- a/src/renderer/handlers/keyboard.ts
+++ b/src/renderer/handlers/keyboard.ts
@@ -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 {
registerDictionaryPopupVisibilityListener,
@@ -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 = {
- 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 | 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({
@@ -91,20 +87,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');
@@ -114,6 +98,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,
statsToggleKey?: string,
@@ -1292,16 +1330,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) => {
@@ -1321,8 +1350,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)) {
@@ -1337,6 +1374,9 @@ export function createKeyboardHandlers(
setupMpvInputForwarding,
refreshConfiguredShortcuts,
updateSessionBindings,
+ refreshMpvInputBindings: () => {
+ void importedMpvBindings.refresh();
+ },
syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility,
diff --git a/src/renderer/handlers/mpv-input-forwarding.test.ts b/src/renderer/handlers/mpv-input-forwarding.test.ts
index 919da743..07f1ed1c 100644
--- a/src/renderer/handlers/mpv-input-forwarding.test.ts
+++ b/src/renderer/handlers/mpv-input-forwarding.test.ts
@@ -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;
diff --git a/src/renderer/handlers/mpv-input-forwarding.ts b/src/renderer/handlers/mpv-input-forwarding.ts
index 53cca512..0dbcef55 100644
--- a/src/renderer/handlers/mpv-input-forwarding.ts
+++ b/src/renderer/handlers/mpv-input-forwarding.ts
@@ -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;
type ForwardedKeyEvent = Parameters[0] &
Pick;
+type ForwardedWheelEvent = Parameters[0] &
+ ModifierState &
+ Pick;
+type ForwardedMouseEvent = ModifierState &
+ Pick;
export function createMpvInputForwarding(deps: {
load: () => Promise;
@@ -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): 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 };
}
diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts
index 23266186..a3efdf35 100644
--- a/src/renderer/modals/session-help-sections.ts
+++ b/src/renderer/modals/session-help-sections.ts
@@ -71,6 +71,10 @@ const KEY_NAME_MAP: Record = {
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 {
diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts
index c52e593a..eec6e991 100644
--- a/src/renderer/renderer.ts
+++ b/src/renderer/renderer.ts
@@ -779,6 +779,9 @@ async function init(): Promise {
});
});
+ // 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();
diff --git a/src/settings/key-input.test.ts b/src/settings/key-input.test.ts
index 90306e7a..e68d4fc7 100644
--- a/src/settings/key-input.test.ts
+++ b/src/settings/key-input.test.ts
@@ -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, []);
diff --git a/src/settings/key-input.ts b/src/settings/key-input.ts
index 354a5a97..72ee593f 100644
--- a/src/settings/key-input.ts
+++ b/src/settings/key-input.ts
@@ -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 &
+ Parameters[0];
+
export interface MpvKeybindingRow {
defaultKey: string;
key: string;
@@ -87,14 +91,6 @@ const MPV_KEY_BY_CODE: Record = {
Tab: 'TAB',
};
-const MPV_MOUSE_BUTTON_BY_BUTTON: Record = {
- 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,
+ key: string,
+ mode: KeyInputMode,
+): string | null {
+ if (mode !== 'dom-code') {
return null;
}
diff --git a/src/settings/settings-keybinding-controls.ts b/src/settings/settings-keybinding-controls.ts
index e740f3c8..0194c209 100644
--- a/src/settings/settings-keybinding-controls.ts
+++ b/src/settings/settings-keybinding-controls.ts
@@ -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;
}
diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts
index b1a929a1..00528f8a 100644
--- a/src/shared/ipc/contracts.ts
+++ b/src/shared/ipc/contracts.ts
@@ -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',
diff --git a/src/shared/mpv-input-bindings.test.ts b/src/shared/mpv-input-bindings.test.ts
index c001fc73..f30583b9 100644
--- a/src/shared/mpv-input-bindings.test.ts
+++ b/src/shared/mpv-input-bindings.test.ts
@@ -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', () => {
diff --git a/src/shared/mpv-input-bindings.ts b/src/shared/mpv-input-bindings.ts
index 4eb344db..5f732723 100644
--- a/src/shared/mpv-input-bindings.ts
+++ b/src/shared/mpv-input-bindings.ts
@@ -16,13 +16,33 @@ const SPECIAL_KEYS: Record = {
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> = {
+ 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([
+ ...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();
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 `. Trackpads yield fractional notches.
+export function wheelEventToMpvWheel(
+ event: Pick,
+): { 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 } = {},
diff --git a/src/types/runtime.ts b/src/types/runtime.ts
index ae886205..41f9598a 100644
--- a/src/types/runtime.ts
+++ b/src/types/runtime.ts
@@ -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;
}